• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

juarezr / solrcopy / 17814558627

18 Sep 2025 12:47AM UTC coverage: 71.375% (-0.3%) from 71.626%
17814558627

push

github

juarezr
Update README.md

1339 of 1876 relevant lines covered (71.38%)

2.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.6
/src/args.rs
1
use super::helpers::{CapturesHelpers, EMPTY_STR, EMPTY_STRING, RegexHelpers, StringHelpers};
2
use clap::builder::styling::{AnsiColor as Ansi, Styles};
3
use clap::{Args, Parser, Subcommand, ValueEnum};
4
use clap_complete::Shell;
5
use log::LevelFilter;
6
use regex::Regex;
7
use simplelog::TerminalMode;
8
use std::{fmt, path::Path, path::PathBuf, str::FromStr};
9
use url::Url;
10

11
// #region Cli arguments
12

13
// #region Cli structs
14

15
const STYLES: Styles = Styles::styled()
16
    .usage(Ansi::BrightYellow.on_default().bold())
17
    .header(Ansi::BrightYellow.on_default().bold())
18
    .literal(Ansi::BrightBlue.on_default())
19
    .placeholder(Ansi::BrightMagenta.on_default())
20
    .error(Ansi::BrightRed.on_default())
21
    .valid(Ansi::BrightGreen.on_default())
22
    .invalid(Ansi::BrightRed.on_default());
23

24
/// Command line tool for backup and restore of documents stored in cores of Apache Solr.
25
///
26
/// Solrcopy is a command for doing backup and restore of documents stored on Solr cores.
27
/// It let you filter docs by using a expression, limit quantity, define order and desired
28
/// columns to export. The data is stored as json inside local zip files. It is agnostic
29
/// to data format, content and storage place. Because of this data is restored exactly
30
/// as extracted and your responsible for extracting, storing and updating the correct data
31
/// from and into correct cores.
32
#[derive(Parser, Debug)]
33
#[command(author, version, about, version, arg_required_else_help = true, styles = STYLES)]
34
pub(crate) struct Cli {
35
    #[command(subcommand)]
36
    pub arguments: Commands,
37
}
38

39
#[derive(Subcommand, Debug)]
40
#[allow(clippy::large_enum_variant)]
41
pub(crate) enum Commands {
42
    /// Dumps documents from a Apache Solr core into local backup files
43
    Backup(Backup),
44
    /// Restore documents from local backup files into a Apache Solr core
45
    Restore(Restore),
46
    /// Perform a commit in the Solr core index for persisting documents in disk/memory
47
    Commit(Execute),
48
    /// Removes documents from the Solr core definitively
49
    Delete(Delete),
50
    /// Create a new empty core in the Solr instance
51
    Create(Execute),
52
    /// Generates man page and completion scripts for different shells
53
    Generate(Generate),
54
}
55

56
#[derive(Parser, Debug)]
57
pub(crate) struct Backup {
58
    /// Solr Query param 'q' for filtering which documents are retrieved
59
    /// See: <https://lucene.apache.org/solr/guide/6_6/the-standard-query-parser.html>
60
    #[arg(short, long, display_order = 40, value_name = "'f1:vl1 AND f2:vl2'")]
61
    pub query: Option<String>,
62

63
    /// Solr Filter Query param 'fq' for filtering which documents are retrieved
64
    #[arg(short = 'f', long, display_order = 41, value_name = "'f1:vl1 AND f2:vl2'")]
65
    pub fq: Option<String>,
66

67
    /// Solr core fields names for sorting documents for retrieval
68
    #[arg(
69
        short,
70
        long,
71
        display_order = 42,
72
        value_name = "f1:asc,f2:desc,...",
73
        value_delimiter = ','
74
    )]
75
    pub order: Vec<SortField>,
76

77
    /// Skip this quantity of documents in the Solr Query
78
    #[arg(short = 'k', long, display_order = 43, value_parser = parse_quantity, default_value_t = 0, value_name = "quantity")]
79
    pub skip: u64,
80

81
    /// Maximum quantity of documents for retrieving from the core (like 100M)
82
    #[arg(short, long, display_order = 44, value_parser = parse_quantity, value_name = "quantity")]
83
    pub limit: Option<u64>,
84

85
    /// Names of core fields retrieved in each document [default: all but _*]
86
    #[arg(short, long, display_order = 45, value_name = "field1,field2,...")]
87
    pub select: Vec<String>,
88

89
    /// Slice the queries by using the variables {begin} and {end} for iterating in `--query`
90
    /// Used in bigger solr cores with huge number of docs because querying the end of docs is expensive and fails frequently
91
    #[arg(short, long, display_order = 50, default_value_t = IterateMode::Day, value_name = "mode", value_enum)]
92
    pub iterate_by: IterateMode,
93

94
    /// The range of dates/numbers for iterating the queries throught slices.
95
    /// Requires that the query parameter contains the variables {begin} and {end} for creating the slices.
96
    /// Use numbers or dates in ISO 8601 format (yyyy-mm-ddTHH:MM:SS)
97
    #[arg(
98
        short = 'b',
99
        long = "between",
100
        display_order = 51,
101
        value_name = "begin> <end",
102
        requires = "query",
103
        number_of_values = 2
104
    )]
105
    pub iterate_between: Vec<String>,
106

107
    /// Number to increment each step in iterative mode
108
    #[arg(
109
        long = "step",
110
        display_order = 52,
111
        default_value_t = 1,
112
        value_name = "num",
113
        // value_parser = clap::value_parser!(u64).range(0..366),
114
    )]
115
    pub iterate_step: u64,
116

117
    /// Number of documents to retrieve from solr in each reader step
118
    #[arg(long, display_order = 70, default_value = "4k", value_parser = parse_quantity, value_name = "quantity")]
119
    pub num_docs: u64,
120

121
    /// Max number of files of documents stored in each zip file
122
    #[arg(long, display_order = 71, default_value_t = 40, value_parser = parse_quantity, value_name = "quantity")]
123
    pub archive_files: u64,
124

125
    /// Optional prefix for naming the zip backup files when storing documents
126
    #[arg(long, display_order = 72, value_parser = parse_file_prefix, value_name = "name")]
127
    pub zip_prefix: Option<String>,
128

129
    /// Use only when your Solr Cloud returns a distinct count of docs for some queries in a row.
130
    /// This may be caused by replication problems between cluster nodes of shard replicas of a core.
131
    /// Response with 'num_found' bellow the greatest value are ignored for getting all possible docs.
132
    /// Use with `--params shards=shard_name` for retrieving all docs for each shard of the core
133
    #[arg(
134
        long,
135
        display_order = 73,
136
        default_value_t = 0,
137
        value_name = "count",
138
        value_parser = clap::value_parser!(u64).range(0..99),
139
    )]
140
    pub workaround_shards: u64,
141

142
    #[command(flatten)]
143
    pub options: CommonArgs,
144

145
    #[command(flatten)]
146
    pub transfer: ParallelArgs,
147
}
148

149
#[derive(Parser, Debug)]
150
pub(crate) struct Restore {
151
    /// Mode to perform commits of the documents transaction log while updating the core
152
    /// [possible values: none, soft, hard, {interval} ]
153
    #[arg(short, long, display_order = 40, default_value = "hard", value_parser = parse_commit_mode, value_name = "mode")]
154
    pub flush: CommitMode,
155

156
    /// Do not perform a final hard commit before finishing
157
    #[arg(long, display_order = 41)]
158
    pub no_final_commit: bool,
159

160
    /// Disable core replication at start and enable again at end
161
    #[arg(long, display_order = 42)]
162
    pub disable_replication: bool,
163

164
    /// Search pattern for matching names of the zip backup files
165
    #[arg(short, long, display_order = 70, value_name = "core*.zip")]
166
    pub search: Option<String>,
167

168
    /// Optional order for searching the zip archives
169
    #[arg(long, display_order = 71, default_value = "none", value_name = "asc | desc")]
170
    pub order: SortOrder,
171

172
    #[command(flatten)]
173
    pub options: CommonArgs,
174

175
    #[command(flatten)]
176
    pub transfer: ParallelArgs,
177
}
178

179
#[derive(Parser, Debug)]
180
pub(crate) struct Delete {
181
    /// Solr Query for filtering which documents are removed in the core.
182
    /// Use '*:*' for excluding all documents in the core.
183
    /// There are no way of recovering excluded docs.
184
    /// Use with caution and check twice.
185
    #[arg(short, long, display_order = 40, value_name = "f1:val1 AND f2:val2")]
186
    pub query: String,
187

188
    /// Wether to perform a commits of transaction log after removing the documents
189
    #[arg(short, display_order = 41, long, default_value = "soft", value_name = "mode")]
190
    pub flush: CommitMode,
191

192
    #[command(flatten)]
193
    pub options: CommonArgs,
194
}
195

196
#[derive(Parser, Debug)]
197
pub(crate) struct Execute {
198
    #[command(flatten)]
199
    pub options: CommonArgs,
200
}
201

202
#[derive(Parser, Debug)]
203
pub(crate) struct Generate {
204
    /// Specifies the shell for which the argument completion script should be generated
205
    #[arg(short, long, display_order = 10, value_parser = parse_shell, required_unless_present_any(["all", "manpage"]))]
206
    pub shell: Option<Shell>,
207

208
    /// Generate a manpage for this application in the output directory
209
    #[arg(short, long, display_order = 69, requires("output_dir"))]
210
    pub manpage: bool,
211

212
    /// Generate argument completion script in the output directory for all supported shells
213
    #[arg(short, long, display_order = 70, requires("output_dir"))]
214
    pub all: bool,
215

216
    /// Write the generated assets to <path/to/output/dir> or to stdout if not specified
217
    #[arg(short, long, display_order = 71, value_name = "path/to/output/dir")]
218
    pub output_dir: Option<PathBuf>,
219
}
220

221
// #endregion
222

223
// #region Cli common
224

225
#[derive(Args, Clone, Debug)]
226
pub(crate) struct CommonArgs {
227
    /// Url pointing to the Solr cluster
228
    #[arg(short, long, display_order = 10, env = SOLR_COPY_URL, value_parser = parse_solr_url, default_value = "http://localhost:8983/solr")]
229
    pub url: String,
230

231
    /// Case sensitive name of the core in the Solr server
232
    #[arg(short, long, display_order = 20, value_name = "core")]
233
    pub core: String,
234

235
    #[command(flatten)]
236
    pub logging: LoggingArgs,
237
}
238

239
#[derive(Parser, Clone, Debug)]
240
pub(crate) struct LoggingArgs {
241
    /// What level of detail should print messages
242
    #[arg(long, display_order = 90, value_name = "level", default_value_t = LevelFilter::Info)]
243
    pub log_level: LevelFilter,
244

245
    /// Terminal output to print messages
246
    #[arg(long, display_order = 91, value_name = "mode", default_value = "mixed", value_parser = parse_terminal_mode)]
247
    pub log_mode: TerminalMode,
248

249
    /// Write messages to a local file
250
    #[arg(long, display_order = 92, value_name = "path")]
251
    pub log_file_path: Option<PathBuf>,
252

253
    /// What level of detail should write messages to the file
254
    #[arg(long, display_order = 93, value_name = "level", default_value_t = LevelFilter::Debug)]
255
    pub log_file_level: LevelFilter,
256
}
257

258
#[derive(Args, Debug)]
259
/// Dumps and restores documents from a Apache Solr core into local backup files
260
pub(crate) struct ParallelArgs {
261
    /// Existing folder where the zip backup files containing the extracted documents are stored
262
    #[arg(short, display_order = 30, long, env = SOLR_COPY_DIR, value_name = "/path/to/output")]
263
    pub dir: PathBuf,
264

265
    /// Extra parameter for Solr Update Handler.
266
    /// See: <https://lucene.apache.org/solr/guide/transforming-and-indexing-custom-json.html>
267
    #[arg(short, long, display_order = 60, value_name = "useParams=mypars")]
268
    pub params: Option<String>,
269

270
    /// How many times should continue on source document errors
271
    #[arg(short, long, display_order = 61, default_value_t = 0, value_name = "count", value_parser = parse_quantity_max)]
272
    pub max_errors: u64,
273

274
    /// Delay before any processing in solr server. Format as: 30s, 15min, 1h
275
    #[arg(long, display_order = 62, default_value_t = 0, value_name = "time", value_parser = parse_millis, hide_default_value = true)]
276
    pub delay_before: u64,
277

278
    /// Delay between each http operations in solr server. Format as: 3s, 500ms, 1min
279
    #[arg(long, display_order = 63, default_value_t = 0, value_name = "time", value_parser = parse_millis, hide_default_value = true)]
280
    pub delay_per_request: u64,
281

282
    /// Delay after all processing. Usefull for letting Solr breath.
283
    #[arg(long, display_order = 64, default_value_t = 0, value_name = "time", value_parser = parse_millis, hide_default_value = true)]
284
    pub delay_after: u64,
285

286
    /// Number parallel threads exchanging documents with the solr core
287
    #[arg(
288
        short,
289
        long,
290
        display_order = 80,
291
        default_value_t = 1,
292
        value_name = "count",
293
        // value_parser = clap::value_parser!(u64).range(1..128),
294
    )]
295
    pub readers: u64,
296

297
    /// Number parallel threads syncing documents with the zip archives
298
    #[arg(
299
        short,
300
        long,
301
        display_order = 80,
302
        default_value_t = 1,
303
        value_name = "count",
304
        // value_parser = clap::value_parser!(u64).range(1..=128),
305
    )]
306
    pub writers: u64,
307
}
308

309
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
310
/// Tells Solrt to performs a commit of the updated documents while updating the core
311
pub(crate) enum CommitMode {
312
    /// Do not perform commit
313
    None,
314
    /// Perform a hard commit by each step for flushing all uncommitted documents in a transaction log to disk
315
    /// This is the safest and the slowest method
316
    Hard,
317
    /// Perform a soft commit of the transaction log for invalidating top-level caches and making documents searchable
318
    Soft,
319
    /// Force a hard commit of the transaction log in the defined milliseconds period
320
    Within { millis: u64 },
321
}
322

323
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
324
/// Used in bigger solr cores with huge number of docs because querying the end of docs is expensive and fails frequently
325
pub(crate) enum IterateMode {
326
    None,
327
    /// Break the query in slices by a first ordered date field repeating between {begin} and {end} in the query parameters
328
    Minute,
329
    Hour,
330
    Day,
331
    /// Break the query in slices by a first ordered integer field repeating between {begin} and {end} in the query parameters
332
    Range,
333
}
334

335
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
336
pub(crate) enum SortOrder {
337
    None,
338
    Asc,
339
    Desc,
340
}
341

342
pub(crate) const SOLR_COPY_DIR: &str = "SOLR_COPY_DIR";
343
pub(crate) const SOLR_COPY_URL: &str = "SOLR_COPY_URL";
344

345
// #endregion
346

347
// #region Param parsing
348

349
fn parse_quantity(src: &str) -> Result<u64, String> {
29✔
350
    lazy_static! {
351
        static ref REGKB: Regex =
352
            Regex::new("^([0-9]+)\\s*([k|m|g|t|K|M|G|T](?:[b|B])?)?$").unwrap();
353
    }
354
    let up = src.trim().to_ascii_uppercase();
29✔
355

356
    match REGKB.get_groups(&up) {
29✔
357
        None => Err(format!("Wrong value: '{}'. Use numbers only, or with suffix: K M G", src)),
×
358
        Some(parts) => {
29✔
359
            let number = parts.get_as_str(1);
29✔
360
            let multiplier = parts.get_as_str(2);
29✔
361
            let parsed = number.parse::<u64>();
29✔
362
            match parsed {
29✔
363
                Err(_) => Err(format!("Wrong value for number: '{}'", src)),
×
364
                Ok(quantity) => match multiplier {
29✔
365
                    "" => Ok(quantity),
29✔
366
                    "K" | "KB" => Ok(quantity * 1000),
8✔
367
                    "M" | "MB" => Ok(quantity * 1_000_000),
4✔
368
                    "G" | "GB" => Ok(quantity * 1_000_000_000),
×
369
                    "T" | "TB" => Ok(quantity * 1_000_000_000_000),
×
370
                    _ => Err(format!(
×
371
                        "Wrong value for quantity multiplier '{}' in '{}'",
×
372
                        multiplier, src
×
373
                    )),
×
374
                },
375
            }
376
        }
377
    }
378
}
29✔
379

380
fn parse_quantity_max(s: &str) -> Result<u64, String> {
7✔
381
    let lower = s.to_ascii_lowercase();
7✔
382
    match lower.as_str() {
7✔
383
        "max" => Ok(u64::MAX),
7✔
384
        _ => match parse_quantity(s) {
7✔
385
            Ok(value) => Ok(value),
7✔
386
            Err(_) => Err(format!("'{}'. [alowed: all, <quantity>]", s)),
×
387
        },
388
    }
389
}
7✔
390

391
fn parse_millis(src: &str) -> Result<u64, String> {
31✔
392
    lazy_static! {
393
        static ref REGKB: Regex = Regex::new("^([0-9]+)\\s*([a-zA-Z]*)$").unwrap();
394
    }
395
    let lower = src.trim().to_ascii_lowercase();
31✔
396

397
    match REGKB.get_groups(&lower) {
31✔
398
        None => Err(format!("Wrong interval: '{}'. Use numbers only, or with suffix: s m h", src)),
×
399
        Some(parts) => {
31✔
400
            let number = parts.get_as_str(1);
31✔
401
            let multiplier = parts.get_as_str(2);
31✔
402
            let parsed = number.parse::<u64>();
31✔
403
            match parsed {
31✔
404
                Err(_) => Err(format!("Wrong value for number: '{}'", src)),
×
405
                Ok(quantity) => match multiplier {
31✔
406
                    "ms" | "millis" | "milliseconds" => Ok(quantity),
31✔
407
                    "" | "s" | "sec" | "secs" | "seconds" => Ok(quantity * 1000),
25✔
408
                    "m" | "min" | "mins" | "minutes" => Ok(quantity * 60_000),
4✔
409
                    "h" | "hr" | "hrs" | "hours" => Ok(quantity * 3_600_000),
1✔
410
                    _ => Err(format!(
×
411
                        "Wrong value for time multiplier '{}' in '{}'",
×
412
                        multiplier, src
×
413
                    )),
×
414
                },
415
            }
416
        }
417
    }
418
}
31✔
419

420
fn parse_solr_url(src: &str) -> Result<String, String> {
16✔
421
    let url2 = if src.starts_with_any(&["http://", "https://"]) {
16✔
422
        src.to_owned()
16✔
423
    } else {
424
        "http://".append(src)
×
425
    };
426
    let parsing = Url::parse(src);
16✔
427
    if let Err(reason) = parsing {
16✔
428
        return Err(format!("Error parsing Solr: {}", reason));
×
429
    }
16✔
430
    let parsed = parsing.unwrap();
16✔
431
    if parsed.scheme() != "http" {
16✔
432
        return Err("Solr url scheme must be http or https as in: http:://server.domain:8983/solr"
×
433
            .to_string());
×
434
    }
16✔
435
    if parsed.query().is_some() {
16✔
436
        return Err("Solr url scheme must be a base url without query parameters as in: \
×
437
                    http:://server.domain:8983/solr"
×
438
            .to_string());
×
439
    }
16✔
440
    if parsed.path_segments().is_none() {
16✔
441
        return Err("Solr url path must be 'api' or 'solr' as in: http:://server.domain:8983/solr"
×
442
            .to_string());
×
443
    } else {
444
        let paths = parsed.path_segments();
16✔
445
        if paths.iter().count() != 1 {
16✔
446
            return Err("Solr url path must not include core name as in: \
×
447
                        http:://server.domain:8983/solr"
×
448
                .to_string());
×
449
        }
16✔
450
    }
451
    Ok(url2)
16✔
452
}
16✔
453

454
fn parse_file_prefix(src: &str) -> Result<String, String> {
3✔
455
    lazy_static! {
456
        static ref REGFN: Regex = Regex::new("^(\\w+)$").unwrap();
457
    }
458
    match REGFN.get_group(src, 1) {
3✔
459
        None => {
460
            Err(format!("Wrong output filename: '{}'. Considere using letters and numbers.", src))
×
461
        }
462
        Some(group1) => Ok(group1.to_string()),
3✔
463
    }
464
}
3✔
465

466
fn parse_commit_mode(s: &str) -> Result<CommitMode, String> {
8✔
467
    let lower = s.to_ascii_lowercase();
8✔
468
    match lower.as_str() {
8✔
469
        "none" => Ok(CommitMode::None),
8✔
470
        "soft" => Ok(CommitMode::Soft),
8✔
471
        "hard" => Ok(CommitMode::Hard),
4✔
472
        _ => match parse_millis(s) {
×
473
            Ok(value) => Ok(CommitMode::Within { millis: value }),
×
474
            Err(_) => Err(format!("'{}'. [alowed: none soft hard <secs>]", s)),
×
475
        },
476
    }
477
}
8✔
478

479
fn parse_terminal_mode(s: &str) -> Result<TerminalMode, String> {
13✔
480
    let lower = s.to_ascii_lowercase();
13✔
481
    match lower.as_str() {
13✔
482
        "stdout" => Ok(TerminalMode::Stdout),
13✔
483
        "stderr" => Ok(TerminalMode::Stderr),
13✔
484
        "mixed" => Ok(TerminalMode::Mixed),
13✔
485
        _ => Err(format!("Invalid terminal mode: {}. [alowed: stdout stderr mixed]", s)),
×
486
    }
487
}
13✔
488

489
fn parse_shell(s: &str) -> Result<Shell, String> {
1✔
490
    let full = PathBuf::from(s);
1✔
491
    let invl = format!("Invalid shell: {}", s);
1✔
492
    let name = full.file_name().unwrap().to_str().ok_or(invl.clone())?;
1✔
493
    let lowr = name.to_ascii_lowercase();
1✔
494
    <Shell as FromStr>::from_str(&lowr).map_err(|_| invl)
1✔
495
}
1✔
496

497
// #endregion
498

499
// #region Cli impl
500

501
impl Commands {
502
    pub(crate) fn validate(&self) -> Result<(), String> {
×
503
        match self {
×
504
            Self::Backup(get) => get.validate(),
×
505
            Self::Restore(put) => put.validate(),
×
506
            _ => Ok(()),
×
507
        }
508
    }
×
509

510
    pub(crate) fn get_options(&self) -> Option<&CommonArgs> {
×
511
        match &self {
×
512
            Self::Backup(get) => Some(&get.options),
×
513
            Self::Restore(put) => Some(&put.options),
×
514
            Self::Commit(com) => Some(&com.options),
×
515
            Self::Delete(del) => Some(&del.options),
×
516
            _ => None,
×
517
        }
518
    }
×
519

520
    pub(crate) fn get_logging(&self) -> LoggingArgs {
×
521
        match self.get_options() {
×
522
            None => LoggingArgs::default().clone(),
×
523
            Some(opt) => opt.get_logging().clone(),
×
524
        }
525
    }
×
526
}
527

528
impl CommonArgs {
529
    pub(crate) fn to_command(&self) -> Execute {
1✔
530
        Execute { options: self.clone() }
1✔
531
    }
1✔
532

533
    pub(crate) fn get_core_handler_url(&self, handler_url_path: &str) -> String {
4✔
534
        #[rustfmt::skip]
535
        let parts: Vec<String> = vec![
4✔
536
            self.url.with_suffix("/"),
4✔
537
            self.core.clone(),
4✔
538
            handler_url_path.with_prefix("/"),
4✔
539
        ];
540
        parts.concat()
4✔
541
    }
4✔
542

543
    pub(crate) fn get_update_url_with(&self, query_string_params: &str) -> String {
3✔
544
        let parts: Vec<String> =
3✔
545
            vec![self.get_core_handler_url("/update"), query_string_params.with_prefix("?")];
3✔
546
        parts.concat()
3✔
547
    }
3✔
548

549
    pub(crate) fn get_update_url(&self) -> String {
2✔
550
        self.get_update_url_with(EMPTY_STR)
2✔
551
    }
2✔
552

553
    pub(crate) fn get_core_admin_v2_url_with(&self, query_string_params: &str) -> String {
1✔
554
        let mut solr_uri = Url::parse(&self.url).unwrap();
1✔
555
        solr_uri.set_path("api/cores");
1✔
556
        let parts: Vec<String> = vec![solr_uri.to_string(), query_string_params.with_prefix("?")];
1✔
557
        parts.concat()
1✔
558
    }
1✔
559

560
    pub(crate) fn get_core_admin_v2_url(&self) -> String {
1✔
561
        self.get_core_admin_v2_url_with(EMPTY_STR)
1✔
562
    }
1✔
563

564
    pub(crate) fn get_logging(&self) -> &LoggingArgs {
4✔
565
        &self.logging
4✔
566
    }
4✔
567

568
    pub(crate) fn is_quiet(&self) -> bool {
2✔
569
        self.logging.log_level == LevelFilter::Off
2✔
570
    }
2✔
571
}
572

573
impl ParallelArgs {
574
    pub(crate) fn get_param(&self, separator: &str) -> String {
5✔
575
        self.params.as_ref().unwrap_or(&EMPTY_STRING).with_prefix(separator)
5✔
576
    }
5✔
577
}
578

579
impl LoggingArgs {
580
    pub(crate) fn is_quiet(&self) -> bool {
×
581
        self.log_level == LevelFilter::Off
×
582
    }
×
583
}
584

585
impl Default for LoggingArgs {
586
    fn default() -> Self {
×
587
        Self {
×
588
            log_level: LevelFilter::Off,
×
589
            log_mode: Default::default(),
×
590
            log_file_path: Default::default(),
×
591
            log_file_level: LevelFilter::Off,
×
592
        }
×
593
    }
×
594
}
595

596
impl Default for CommitMode {
597
    fn default() -> Self {
×
598
        CommitMode::Within { millis: 40_000 }
×
599
    }
×
600
}
601

602
impl FromStr for CommitMode {
603
    type Err = String;
604

605
    fn from_str(s: &str) -> Result<Self, Self::Err> {
3✔
606
        parse_commit_mode(s)
3✔
607
    }
3✔
608
}
609

610
impl CommitMode {
611
    pub(crate) fn as_param(&self, separator: &str) -> String {
3✔
612
        match self {
3✔
613
            CommitMode::Soft => separator.append("softCommit=true"),
1✔
614
            CommitMode::Hard => separator.append("commit=true"),
2✔
615
            CommitMode::Within { millis } => format!("{}commitWithin={}", separator, millis),
×
616
            _ => EMPTY_STRING,
×
617
        }
618
    }
3✔
619

620
    // pub (crate) fn as_xml(&self, separator: &str) -> String {
621
    //     match self {
622
    //         CommitMode::Soft => separator.append("<commit />"),
623
    //         CommitMode::Hard => separator.append("<commit />"),
624
    //         CommitMode::Within { millis } => {
625
    //             separator.append(format!("<commitWithin>{}</commitWithin>", millis).as_str())
626
    //         }
627
    //         _ => EMPTY_STRING,
628
    //     }
629
    // }
630
}
631

632
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
633
pub(crate) enum SortDirection {
634
    Asc,
635
    Desc,
636
}
637

638
#[derive(Parser, Clone, PartialEq, Eq, PartialOrd, Ord)]
639
pub(crate) struct SortField {
640
    pub field: String,
641
    pub direction: SortDirection,
642
}
643

644
impl fmt::Display for SortField {
645
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6✔
646
        write!(f, "{}%20{:?}", self.field, self.direction)
6✔
647
    }
6✔
648
}
649

650
impl fmt::Debug for SortField {
651
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
652
        write!(f, "{}:{:?}", self.field, self.direction)
×
653
    }
×
654
}
655

656
impl FromStr for SortField {
657
    type Err = String;
658

659
    fn from_str(s: &str) -> Result<Self, Self::Err> {
9✔
660
        if s.is_empty() {
9✔
661
            Err("missing value".to_string())
×
662
        } else {
663
            lazy_static! {
664
                static ref REO: Regex = Regex::new("^(\\w+)(([:\\s=])(asc|desc))?$").unwrap();
665
            }
666
            match REO.captures(s) {
9✔
667
                None => Err(s.to_string()),
×
668
                Some(cap) => {
9✔
669
                    let sort_dir = if cap.get_as_str(4) == "desc" {
9✔
670
                        SortDirection::Desc
3✔
671
                    } else {
672
                        SortDirection::Asc
6✔
673
                    };
674
                    let sort_field = cap.get_as_str(1).to_string();
9✔
675
                    Ok(SortField { field: sort_field, direction: sort_dir })
9✔
676
                }
677
            }
678
        }
679
    }
9✔
680
}
681

682
impl Generate {
683
    pub(crate) fn get_shells(&self) -> Vec<Shell> {
1✔
684
        let sh: Option<Shell> = if self.all { None } else { self.shell };
1✔
685
        match sh {
1✔
686
            Some(sh1) => vec![sh1],
×
687
            None => Shell::value_variants().to_vec(),
1✔
688
        }
689
    }
1✔
690
}
691

692
// #endregion
693

694
// #region Cli validation
695

696
pub(crate) trait Validation {
697
    fn validate(&self) -> Result<(), String> {
×
698
        Ok(())
×
699
    }
×
700
}
701

702
impl Validation for Backup {
703
    fn validate(&self) -> Result<(), String> {
×
704
        assert_dir_exists(&self.transfer.dir)
×
705
    }
×
706
}
707

708
impl Validation for Restore {
709
    fn validate(&self) -> Result<(), String> {
×
710
        assert_dir_exists(&self.transfer.dir)
×
711
    }
×
712
}
713

714
fn assert_dir_exists(dir: &Path) -> Result<(), String> {
×
715
    if !dir.exists() {
×
716
        Err(format!("Missing folder of zip backup files: {:?}", dir))
×
717
    } else {
718
        Ok(())
×
719
    }
720
}
×
721

722
// #endregion
723

724
// #endregion
725

726
#[cfg(test)]
727
pub(crate) mod shared {
728

729
    use crate::args::{Cli, Commands};
730
    use clap::Parser;
731

732
    pub(crate) const TEST_SELECT_FIELDS: &'static str = "id,date,vehiclePlate";
733

734
    impl Cli {
735
        pub(crate) fn mockup_from(argm: &[&str]) -> Commands {
2✔
736
            Self::parse_from(argm).arguments
2✔
737
        }
2✔
738

739
        pub(crate) fn mockup_and_panic(argm: &[&str]) -> Commands {
5✔
740
            let unknown = &["--unknown", "argument"];
5✔
741
            let combined = [argm, unknown].concat();
5✔
742
            let res = Self::try_parse_from(combined);
5✔
743
            res.unwrap().arguments
5✔
744
        }
5✔
745

746
        pub(crate) fn mockup_for_help(argm: &[&str]) {
4✔
747
            match Self::try_parse_from(argm) {
4✔
748
                Ok(ocli) => {
×
749
                    panic!("Ok parsing CLI arguments: {} -> {:?}", argm.join(" "), ocli)
×
750
                }
751
                Err(ef) => {
4✔
752
                    println!("Err parsing CLI arguments: {}", ef)
4✔
753
                }
754
            }
755
        }
4✔
756
    }
757
}
758

759
#[cfg(test)]
760
pub(crate) mod tests {
761

762
    // #region Mockup
763

764
    use super::shared::TEST_SELECT_FIELDS;
765
    use super::{Cli, Commands, CommitMode, parse_millis, parse_quantity};
766
    use clap::Parser;
767
    use clap_complete::Shell::Bash;
768
    use log::LevelFilter;
769
    use pretty_assertions::assert_eq;
770
    use std::path::PathBuf;
771

772
    impl Cli {
773
        pub(crate) fn mockup_args_backup() -> Commands {
2✔
774
            Self::parse_from(TEST_ARGS_BACKUP).arguments
2✔
775
        }
2✔
776

777
        pub(crate) fn mockup_args_restore() -> Commands {
3✔
778
            Self::parse_from(TEST_ARGS_RESTORE).arguments
3✔
779
        }
3✔
780

781
        pub(crate) fn mockup_args_commit() -> Commands {
1✔
782
            Self::parse_from(TEST_ARGS_COMMIT).arguments
1✔
783
        }
1✔
784
    }
785

786
    // #endregion Mockup
787

788
    // #region CLI Args
789

790
    const TEST_ARGS_HELP: &'static [&'static str] = &["solrcopy", "--help"];
791

792
    const TEST_ARGS_VERSION: &'static [&'static str] = &["solrcopy", "--version"];
793

794
    const TEST_ARGS_HELP_BACKUP: &'static [&'static str] = &["solrcopy", "help", "backup"];
795

796
    const TEST_ARGS_HELP_RESTORE: &'static [&'static str] = &["solrcopy", "help", "restore"];
797

798
    const TEST_ARGS_BACKUP: &'static [&'static str] = &[
799
        "solrcopy",
800
        "backup",
801
        "--url",
802
        "http://solr-server.com:8983/solr",
803
        "--core",
804
        "demo",
805
        "--dir",
806
        "./tmp",
807
        "--query",
808
        "ownerId:173826 AND date:[{begin} TO {end}]",
809
        "--order",
810
        "date:asc,id:desc,vehiclePlate:asc",
811
        "--select",
812
        TEST_SELECT_FIELDS,
813
        "--between",
814
        "2020-05-01",
815
        "2020-05-04T11:12:13",
816
        "--zip-prefix",
817
        "zip_filename",
818
        "--skip",
819
        "3",
820
        "--limit",
821
        "42",
822
        "--num-docs",
823
        "5",
824
        "--archive-files",
825
        "6",
826
        "--delay-after",
827
        "5s",
828
        "--readers",
829
        "7",
830
        "--writers",
831
        "9",
832
        "--log-level",
833
        "debug",
834
        "--log-mode",
835
        "mixed",
836
        "--log-file-level",
837
        "debug",
838
        "--log-file-path",
839
        "/tmp/test.log",
840
    ];
841

842
    const TEST_ARGS_RESTORE: &'static [&'static str] = &[
843
        "solrcopy",
844
        "restore",
845
        "--url",
846
        "http://solr-server.com:8983/solr",
847
        "--dir",
848
        "./tmp",
849
        "--core",
850
        "target",
851
        "--search",
852
        "*.zip",
853
        "--flush",
854
        "soft",
855
        "--delay-per-request",
856
        "500ms",
857
        "--log-level",
858
        "debug",
859
    ];
860

861
    const TEST_ARGS_COMMIT: &'static [&'static str] = &[
862
        "solrcopy",
863
        "commit",
864
        "--url",
865
        "http://solr-server.com:8983/solr",
866
        "--core",
867
        "demo",
868
        "--log-level",
869
        "debug",
870
    ];
871

872
    const TEST_ARGS_DELETE: &'static [&'static str] = &[
873
        "solrcopy",
874
        "delete",
875
        "--url",
876
        "http://solr-server.com:8983/solr",
877
        "--core",
878
        "demo",
879
        "--query",
880
        "*:*",
881
        "--flush",
882
        "hard",
883
        "--log-level",
884
        "debug",
885
        "--log-file-level",
886
        "error",
887
    ];
888

889
    const TEST_ARGS_GENERATE: &'static [&'static str] =
890
        &["solrcopy", "generate", "--shell", "bash", "--output-dir", "target"];
891

892
    // #endregion
893

894
    // #region Tests
895

896
    #[test]
897
    fn check_params_backup() {
1✔
898
        let parsed = Cli::mockup_args_backup();
1✔
899
        match parsed {
1✔
900
            Commands::Backup(get) => {
1✔
901
                assert_eq!(get.options.url, TEST_ARGS_BACKUP[3]);
1✔
902
                assert_eq!(get.options.core, TEST_ARGS_BACKUP[5]);
1✔
903
                assert_eq!(get.transfer.dir.to_str().unwrap(), TEST_ARGS_BACKUP[7]);
1✔
904
                assert_eq!(get.query, Some(TEST_ARGS_BACKUP[9].to_string()));
1✔
905
                assert_eq!(get.skip, 3);
1✔
906
                assert_eq!(get.limit, Some(42));
1✔
907
                assert_eq!(get.num_docs, 5);
1✔
908
                assert_eq!(get.archive_files, 6);
1✔
909
                assert_eq!(get.transfer.readers, 7);
1✔
910
                assert_eq!(get.transfer.writers, 9);
1✔
911
                assert_eq!(get.options.get_logging().log_level, LevelFilter::Debug);
1✔
912
            }
913
            _ => panic!("command must be 'backup' !"),
×
914
        };
915
    }
1✔
916

917
    #[test]
918
    fn check_params_restore() {
1✔
919
        let parsed = Cli::mockup_args_restore();
1✔
920
        match parsed {
1✔
921
            Commands::Restore(put) => {
1✔
922
                assert_eq!(put.options.url, TEST_ARGS_RESTORE[3]);
1✔
923
                assert_eq!(put.transfer.dir.to_str().unwrap(), TEST_ARGS_RESTORE[5]);
1✔
924
                assert_eq!(put.options.core, TEST_ARGS_RESTORE[7]);
1✔
925
                assert_eq!(put.search.unwrap(), TEST_ARGS_RESTORE[9]);
1✔
926
                assert_eq!(put.flush, CommitMode::Soft);
1✔
927
                assert_eq!(put.flush.as_param("?"), "?softCommit=true");
1✔
928
                assert_eq!(put.options.get_logging().log_level, LevelFilter::Debug);
1✔
929
            }
930
            _ => panic!("command must be 'restore' !"),
×
931
        };
932
    }
1✔
933

934
    #[test]
935
    fn check_params_commit() {
1✔
936
        let parsed = Cli::mockup_args_commit();
1✔
937
        match parsed {
1✔
938
            Commands::Commit(put) => {
1✔
939
                assert_eq!(put.options.url, TEST_ARGS_COMMIT[3]);
1✔
940
                assert_eq!(put.options.core, TEST_ARGS_COMMIT[5]);
1✔
941
                assert_eq!(put.options.get_logging().log_level, LevelFilter::Debug);
1✔
942
            }
943
            _ => panic!("command must be 'commit' !"),
×
944
        };
945
    }
1✔
946

947
    #[test]
948
    fn check_params_delete() {
1✔
949
        let parsed = Cli::mockup_from(TEST_ARGS_DELETE);
1✔
950
        match parsed {
1✔
951
            Commands::Delete(res) => {
1✔
952
                assert_eq!(res.options.url, TEST_ARGS_DELETE[3]);
1✔
953
                assert_eq!(res.options.core, TEST_ARGS_DELETE[5]);
1✔
954
                assert_eq!(res.query, TEST_ARGS_DELETE[7]);
1✔
955
                assert_eq!(res.flush, CommitMode::Hard);
1✔
956
                let logs = res.options.get_logging();
1✔
957
                assert_eq!(logs.log_level, LevelFilter::Debug);
1✔
958
                assert_eq!(logs.log_file_level, LevelFilter::Error);
1✔
959
            }
960
            _ => panic!("command must be 'delete' !"),
×
961
        };
962
    }
1✔
963

964
    #[test]
965
    fn check_params_generate() {
1✔
966
        let parsed = Cli::mockup_from(TEST_ARGS_GENERATE);
1✔
967
        match parsed {
1✔
968
            Commands::Generate(res) => {
1✔
969
                assert_eq!(res.shell, Some(Bash));
1✔
970
                assert_eq!(res.output_dir, Some(PathBuf::from("target")));
1✔
971
            }
972
            _ => panic!("command must be 'generate' !"),
×
973
        };
974
    }
1✔
975

976
    #[test]
977
    fn check_params_help() {
1✔
978
        Cli::mockup_for_help(TEST_ARGS_HELP);
1✔
979
    }
1✔
980

981
    #[test]
982
    fn check_params_version() {
1✔
983
        Cli::mockup_for_help(TEST_ARGS_VERSION);
1✔
984
    }
1✔
985

986
    #[test]
987
    fn check_params_get_help() {
1✔
988
        Cli::mockup_for_help(TEST_ARGS_HELP_BACKUP);
1✔
989
    }
1✔
990

991
    #[test]
992
    fn check_params_put_help() {
1✔
993
        Cli::mockup_for_help(TEST_ARGS_HELP_RESTORE);
1✔
994
    }
1✔
995

996
    #[test]
997
    #[should_panic]
998
    fn check_parse_unknown() {
1✔
999
        Cli::mockup_and_panic(&["solrcopy"]);
1✔
1000
    }
1✔
1001

1002
    #[test]
1003
    #[should_panic]
1004
    fn check_parse_backup_unknown() {
1✔
1005
        Cli::mockup_and_panic(TEST_ARGS_BACKUP);
1✔
1006
    }
1✔
1007

1008
    #[test]
1009
    #[should_panic]
1010
    fn check_parse_restore_unknown() {
1✔
1011
        Cli::mockup_and_panic(TEST_ARGS_RESTORE);
1✔
1012
    }
1✔
1013

1014
    #[test]
1015
    #[should_panic]
1016
    fn check_parse_commit_unknown() {
1✔
1017
        Cli::mockup_and_panic(TEST_ARGS_COMMIT);
1✔
1018
    }
1✔
1019

1020
    #[test]
1021
    #[should_panic]
1022
    fn check_parse_delete_unknown() {
1✔
1023
        Cli::mockup_and_panic(TEST_ARGS_DELETE);
1✔
1024
    }
1✔
1025

1026
    #[test]
1027
    fn check_parse_quantity() {
1✔
1028
        assert_eq!(parse_quantity("3k"), Ok(3_000));
1✔
1029
        assert_eq!(parse_quantity("4 k"), Ok(4_000));
1✔
1030
        assert_eq!(parse_quantity("5kb"), Ok(5_000));
1✔
1031
        assert_eq!(parse_quantity("666m"), Ok(666_000_000));
1✔
1032
        assert_eq!(parse_quantity("777mb"), Ok(777_000_000));
1✔
1033
        assert_eq!(parse_quantity("888mb"), Ok(888_000_000));
1✔
1034
        assert_eq!(parse_quantity("999 mb"), Ok(999_000_000));
1✔
1035
    }
1✔
1036

1037
    #[test]
1038
    fn check_parse_millis() {
1✔
1039
        assert_eq!(parse_millis("3ms"), Ok(3));
1✔
1040
        assert_eq!(parse_millis("4 ms"), Ok(4));
1✔
1041
        assert_eq!(parse_millis("5s"), Ok(5_000));
1✔
1042
        assert_eq!(parse_millis("666s"), Ok(666_000));
1✔
1043
        assert_eq!(parse_millis("7m"), Ok(420_000));
1✔
1044
        assert_eq!(parse_millis("8min"), Ok(480_000));
1✔
1045
        assert_eq!(parse_millis("9 minutes"), Ok(540_000));
1✔
1046
        assert_eq!(parse_millis("10h"), Ok(36_000_000));
1✔
1047
    }
1✔
1048

1049
    // #endregion
1050
}
1051

1052
// end of file
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc