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

juarezr / solrcopy / 18363067970

09 Oct 2025 01:51AM UTC coverage: 81.743% (+15.5%) from 66.205%
18363067970

push

github

web-flow
Merge pull request #39 from juarezr/feat/enhancements

Enhancements and Improvements:

- Added --archive-compression flag to the backup command with support for three compression methods
- Comprehensive Makefile.toml with organized task categories
- Coverage reporting with HTML output generation
- VS Code configuration with recommended extensions

343 of 346 new or added lines in 7 files covered. (99.13%)

4 existing lines in 3 files now uncovered.

1791 of 2191 relevant lines covered (81.74%)

3.53 hits per line

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

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

12
// #region Cli arguments
13

14
// #region Cli structs
15

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

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

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

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

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

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

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

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

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

90
    /// Names of core fields excluded in each document [default: none]
91
    #[arg(short, long, display_order = 46, value_name = "field1,field2,...", value_parser = parse_trim, value_delimiter = ',')]
92
    pub exclude: Vec<String>,
93

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

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

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

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

126
    /// Max number of files of documents stored in each archive file
127
    #[arg(long, display_order = 71, default_value_t = 40, value_parser = parse_quantity, value_name = "quantity")]
128
    pub archive_files: u64,
129

130
    /// Optional prefix for naming the archive backup files when storing documents
131
    #[arg(long, display_order = 72, value_parser = parse_file_prefix, value_name = "name")]
132
    pub archive_prefix: Option<String>,
133

134
    /// Compression method to use for compressing the archive files
135
    /// [possible values: stored, zip, zstd ]
136
    #[arg(long, display_order = 73, default_value = "zip", value_parser = parse_compression, value_name = "compression")]
137
    pub archive_compression: Compression,
138

139
    /// Use only when your Solr Cloud returns a distinct count of docs for some queries in a row.
140
    /// This may be caused by replication problems between cluster nodes of shard replicas of a core.
141
    /// Response with 'num_found' bellow the greatest value are ignored for getting all possible docs.
142
    /// Use with `--params shards=shard_name` for retrieving all docs for each shard of the core
143
    #[arg(
144
        long,
145
        display_order = 73,
146
        default_value_t = 0,
147
        value_name = "count",
148
        value_parser = clap::value_parser!(u64).range(0..99),
149
    )]
150
    pub workaround_shards: u64,
151

152
    #[command(flatten)]
153
    pub options: CommonArgs,
154

155
    #[command(flatten)]
156
    pub transfer: ParallelArgs,
157
}
158

159
#[derive(Parser, Debug)]
160
pub(crate) struct Restore {
161
    /// Mode to perform commits of the documents transaction log while updating the core
162
    /// [possible values: none, soft, hard, {interval} ]
163
    #[arg(short, long, display_order = 40, default_value = "hard", value_parser = parse_commit_mode, value_name = "mode")]
164
    pub flush: CommitMode,
165

166
    /// Do not perform a final hard commit before finishing
167
    #[arg(long, display_order = 41)]
168
    pub no_final_commit: bool,
169

170
    /// Disable core replication at start and enable again at end
171
    #[arg(long, display_order = 42)]
172
    pub disable_replication: bool,
173

174
    /// Search pattern for matching names of the archive backup files
175
    #[arg(short, long, display_order = 70, value_name = "core*.zip")]
176
    pub search: Option<String>,
177

178
    /// Optional order for searching the archive files
179
    #[arg(long, display_order = 71, default_value = "none", value_name = "asc | desc")]
180
    pub order: SortOrder,
181

182
    #[command(flatten)]
183
    pub options: CommonArgs,
184

185
    #[command(flatten)]
186
    pub transfer: ParallelArgs,
187
}
188

189
#[derive(Parser, Debug)]
190
pub(crate) struct Delete {
191
    /// Solr Query for filtering which documents are removed in the core.
192
    /// Use '*:*' for excluding all documents in the core.
193
    /// There are no way of recovering excluded docs.
194
    /// Use with caution and check twice.
195
    #[arg(short, long, display_order = 40, value_name = "f1:val1 AND f2:val2")]
196
    pub query: String,
197

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

202
    #[command(flatten)]
203
    pub options: CommonArgs,
204
}
205

206
#[derive(Parser, Debug)]
207
pub(crate) struct Execute {
208
    #[command(flatten)]
209
    pub options: CommonArgs,
210
}
211

212
#[derive(Parser, Debug)]
213
pub(crate) struct Generate {
214
    /// Specifies the shell for which the argument completion script should be generated
215
    #[arg(short, long, display_order = 10, value_parser = parse_shell, required_unless_present_any(["all", "manpage"]))]
216
    pub shell: Option<Shell>,
217

218
    /// Generate a manpage for this application in the output directory
219
    #[arg(short, long, display_order = 69, requires("output_dir"))]
220
    pub manpage: bool,
221

222
    /// Generate argument completion script in the output directory for all supported shells
223
    #[arg(short, long, display_order = 70, requires("output_dir"))]
224
    pub all: bool,
225

226
    /// Write the generated assets to <path/to/output/dir> or to stdout if not specified
227
    #[arg(short, long, display_order = 71, value_name = "path/to/output/dir")]
228
    pub output_dir: Option<PathBuf>,
229
}
230

231
// #endregion
232

233
// #region Cli common
234

235
#[derive(Args, Clone, Debug)]
236
pub(crate) struct CommonArgs {
237
    /// Url pointing to the Solr cluster
238
    #[arg(short, long, display_order = 10, env = SOLR_COPY_URL, value_parser = parse_solr_url, default_value = "http://localhost:8983/solr")]
239
    pub url: String,
240

241
    /// Case sensitive name of the core in the Solr server
242
    #[arg(short, long, display_order = 20, value_name = "core")]
243
    pub core: String,
244

245
    #[command(flatten)]
246
    pub logging: LoggingArgs,
247
}
248

249
#[derive(Parser, Clone, Debug)]
250
pub(crate) struct LoggingArgs {
251
    /// What level of detail should print messages
252
    #[arg(long, display_order = 90, value_name = "level", default_value_t = LevelFilter::Info)]
253
    pub log_level: LevelFilter,
254

255
    /// Terminal output to print messages
256
    #[arg(long, display_order = 91, value_name = "mode", default_value = "mixed", value_parser = parse_terminal_mode)]
257
    pub log_mode: TerminalMode,
258

259
    /// Write messages to a local file
260
    #[arg(long, display_order = 92, value_name = "path")]
261
    pub log_file_path: Option<PathBuf>,
262

263
    /// What level of detail should write messages to the file
264
    #[arg(long, display_order = 93, value_name = "level", default_value_t = LevelFilter::Debug)]
265
    pub log_file_level: LevelFilter,
266
}
267

268
#[derive(Args, Debug)]
269
/// Dumps and restores documents from a Apache Solr core into local backup files
270
pub(crate) struct ParallelArgs {
271
    /// Existing folder where the backuped files containing the extracted documents are stored
272
    #[arg(short, display_order = 30, long, env = SOLR_COPY_DIR, value_name = "/path/to/output")]
273
    pub dir: PathBuf,
274

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

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

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

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

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

296
    /// Number parallel threads exchanging documents with the solr core
297
    #[arg(
298
        short,
299
        long,
300
        display_order = 80,
301
        default_value_t = 1,
302
        value_name = "count",
303
        // value_parser = clap::value_parser!(u64).range(1..128),
304
    )]
305
    pub readers: u64,
306

307
    /// Number parallel threads syncing documents with the archives files
308
    #[arg(
309
        short,
310
        long,
311
        display_order = 80,
312
        default_value_t = 1,
313
        value_name = "count",
314
        // value_parser = clap::value_parser!(u64).range(1..=128),
315
    )]
316
    pub writers: u64,
317
}
318

319
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
320
/// Tells Solrt to performs a commit of the updated documents while updating the core
321
pub(crate) enum CommitMode {
322
    /// Do not perform commit
323
    None,
324
    /// Perform a hard commit by each step for flushing all uncommitted documents in a transaction log to disk
325
    /// This is the safest and the slowest method
326
    Hard,
327
    /// Perform a soft commit of the transaction log for invalidating top-level caches and making documents searchable
328
    Soft,
329
    /// Force a hard commit of the transaction log in the defined milliseconds period
330
    Within { millis: u64 },
331
}
332

333
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
334
/// Used in bigger solr cores with huge number of docs because querying the end of docs is expensive and fails frequently
335
pub(crate) enum IterateMode {
336
    None,
337
    /// Break the query in slices by a first ordered date field repeating between {begin} and {end} in the query parameters
338
    Minute,
339
    Hour,
340
    Day,
341
    /// Break the query in slices by a first ordered integer field repeating between {begin} and {end} in the query parameters
342
    Range,
343
}
344

345
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
346
pub(crate) enum SortOrder {
347
    None,
348
    Asc,
349
    Desc,
350
}
351

352
pub(crate) const SOLR_COPY_DIR: &str = "SOLR_COPY_DIR";
353
pub(crate) const SOLR_COPY_URL: &str = "SOLR_COPY_URL";
354

355
// #endregion
356

357
// #region Param parsing
358

359
fn parse_trim(src: &str) -> Result<String, String> {
9✔
360
    Ok(src.trim().to_string())
9✔
361
}
9✔
362

363
fn parse_quantity(src: &str) -> Result<u64, String> {
33✔
364
    lazy_static! {
365
        static ref REGKB: Regex =
366
            Regex::new("^([0-9]+)\\s*([k|m|g|t|K|M|G|T](?:[b|B])?)?$").unwrap();
367
    }
368
    let up = src.trim().to_ascii_uppercase();
33✔
369

370
    match REGKB.get_groups(&up) {
33✔
371
        None => Err(format!("Wrong value: '{}'. Use numbers only, or with suffix: K M G", src)),
×
372
        Some(parts) => {
33✔
373
            let number = parts.get_as_str(1);
33✔
374
            let multiplier = parts.get_as_str(2);
33✔
375
            let parsed = number.parse::<u64>();
33✔
376
            match parsed {
33✔
377
                Err(_) => Err(format!("Wrong value for number: '{}'", src)),
×
378
                Ok(quantity) => match multiplier {
33✔
379
                    "" => Ok(quantity),
33✔
380
                    "K" | "KB" => Ok(quantity * 1000),
9✔
381
                    "M" | "MB" => Ok(quantity * 1_000_000),
4✔
382
                    "G" | "GB" => Ok(quantity * 1_000_000_000),
×
383
                    "T" | "TB" => Ok(quantity * 1_000_000_000_000),
×
384
                    _ => Err(format!(
×
385
                        "Wrong value for quantity multiplier '{}' in '{}'",
×
386
                        multiplier, src
×
387
                    )),
×
388
                },
389
            }
390
        }
391
    }
392
}
33✔
393

394
fn parse_quantity_max(s: &str) -> Result<u64, String> {
8✔
395
    let lower = s.to_ascii_lowercase();
8✔
396
    match lower.as_str() {
8✔
397
        "max" => Ok(u64::MAX),
8✔
398
        _ => match parse_quantity(s) {
8✔
399
            Ok(value) => Ok(value),
8✔
400
            Err(_) => Err(format!("'{}'. [alowed: all, <quantity>]", s)),
×
401
        },
402
    }
403
}
8✔
404

405
fn parse_millis(src: &str) -> Result<u64, String> {
34✔
406
    lazy_static! {
407
        static ref REGKB: Regex = Regex::new("^([0-9]+)\\s*([a-zA-Z]*)$").unwrap();
408
    }
409
    let lower = src.trim().to_ascii_lowercase();
34✔
410

411
    match REGKB.get_groups(&lower) {
34✔
412
        None => Err(format!("Wrong interval: '{}'. Use numbers only, or with suffix: s m h", src)),
×
413
        Some(parts) => {
34✔
414
            let number = parts.get_as_str(1);
34✔
415
            let multiplier = parts.get_as_str(2);
34✔
416
            let parsed = number.parse::<u64>();
34✔
417
            match parsed {
34✔
418
                Err(_) => Err(format!("Wrong value for number: '{}'", src)),
×
419
                Ok(quantity) => match multiplier {
34✔
420
                    "ms" | "millis" | "milliseconds" => Ok(quantity),
34✔
421
                    "" | "s" | "sec" | "secs" | "seconds" => Ok(quantity * 1000),
28✔
422
                    "m" | "min" | "mins" | "minutes" => Ok(quantity * 60_000),
4✔
423
                    "h" | "hr" | "hrs" | "hours" => Ok(quantity * 3_600_000),
1✔
424
                    _ => Err(format!(
×
425
                        "Wrong value for time multiplier '{}' in '{}'",
×
426
                        multiplier, src
×
427
                    )),
×
428
                },
429
            }
430
        }
431
    }
432
}
34✔
433

434
fn parse_solr_url(src: &str) -> Result<String, String> {
17✔
435
    let url2 = if src.starts_with_any(&["http://", "https://"]) {
17✔
436
        src.to_owned()
17✔
437
    } else {
438
        "http://".append(src)
×
439
    };
440
    let parsing = Url::parse(src);
17✔
441
    if let Err(reason) = parsing {
17✔
442
        return Err(format!("Error parsing Solr: {}", reason));
×
443
    }
17✔
444
    let parsed = parsing.unwrap();
17✔
445
    if parsed.scheme() != "http" {
17✔
446
        return Err("Solr url scheme must be http or https as in: http:://server.domain:8983/solr"
×
447
            .to_string());
×
448
    }
17✔
449
    if parsed.query().is_some() {
17✔
450
        return Err("Solr url scheme must be a base url without query parameters as in: \
×
451
                    http:://server.domain:8983/solr"
×
452
            .to_string());
×
453
    }
17✔
454
    if parsed.path_segments().is_none() {
17✔
455
        return Err("Solr url path must be 'api' or 'solr' as in: http:://server.domain:8983/solr"
×
456
            .to_string());
×
457
    } else {
458
        let paths = parsed.path_segments();
17✔
459
        if paths.iter().count() != 1 {
17✔
460
            return Err("Solr url path must not include core name as in: \
×
461
                        http:://server.domain:8983/solr"
×
462
                .to_string());
×
463
        }
17✔
464
    }
465
    Ok(url2)
17✔
466
}
17✔
467

468
fn parse_file_prefix(src: &str) -> Result<String, String> {
3✔
469
    lazy_static! {
470
        static ref REGFN: Regex = Regex::new("^(\\w+)$").unwrap();
471
    }
472
    match REGFN.get_group(src, 1) {
3✔
473
        None => {
474
            Err(format!("Wrong output filename: '{}'. Considere using letters and numbers.", src))
×
475
        }
476
        Some(group1) => Ok(group1.to_string()),
3✔
477
    }
478
}
3✔
479

480
fn parse_compression(s: &str) -> Result<Compression, String> {
4✔
481
    let lower = s.to_ascii_lowercase();
4✔
482
    match lower.as_str() {
4✔
483
        "stored" => Ok(Compression::Stored),
4✔
484
        "zip" => Ok(Compression::Zip),
4✔
485
        "zstd" => Ok(Compression::Zstd),
1✔
NEW
486
        _ => Err(format!("'{}'. [alowed: stored zip zstd]", s)),
×
487
    }
488
}
4✔
489

490
fn parse_commit_mode(s: &str) -> Result<CommitMode, String> {
8✔
491
    let lower = s.to_ascii_lowercase();
8✔
492
    match lower.as_str() {
8✔
493
        "none" => Ok(CommitMode::None),
8✔
494
        "soft" => Ok(CommitMode::Soft),
8✔
495
        "hard" => Ok(CommitMode::Hard),
4✔
496
        _ => match parse_millis(s) {
×
497
            Ok(value) => Ok(CommitMode::Within { millis: value }),
×
498
            Err(_) => Err(format!("'{}'. [alowed: none soft hard <secs>]", s)),
×
499
        },
500
    }
501
}
8✔
502

503
fn parse_terminal_mode(s: &str) -> Result<TerminalMode, String> {
14✔
504
    let lower = s.to_ascii_lowercase();
14✔
505
    match lower.as_str() {
14✔
506
        "stdout" => Ok(TerminalMode::Stdout),
14✔
507
        "stderr" => Ok(TerminalMode::Stderr),
14✔
508
        "mixed" => Ok(TerminalMode::Mixed),
14✔
509
        _ => Err(format!("Invalid terminal mode: {}. [alowed: stdout stderr mixed]", s)),
×
510
    }
511
}
14✔
512

513
fn parse_shell(s: &str) -> Result<Shell, String> {
1✔
514
    let full = PathBuf::from(s);
1✔
515
    let invl = format!("Invalid shell: {}", s);
1✔
516
    let name = full.file_name().unwrap().to_str().ok_or(invl.clone())?;
1✔
517
    let lowr = name.to_ascii_lowercase();
1✔
518
    <Shell as FromStr>::from_str(&lowr).map_err(|_| invl)
1✔
519
}
1✔
520

521
// #endregion
522

523
// #region Cli impl
524

525
impl Commands {
526
    pub(crate) fn validate(&self) -> Result<(), String> {
×
527
        match self {
×
528
            Self::Backup(get) => get.validate(),
×
529
            Self::Restore(put) => put.validate(),
×
530
            _ => Ok(()),
×
531
        }
532
    }
×
533

534
    pub(crate) fn get_options(&self) -> Option<&CommonArgs> {
×
535
        match &self {
×
536
            Self::Backup(get) => Some(&get.options),
×
537
            Self::Restore(put) => Some(&put.options),
×
538
            Self::Commit(com) => Some(&com.options),
×
539
            Self::Delete(del) => Some(&del.options),
×
540
            _ => None,
×
541
        }
542
    }
×
543

544
    pub(crate) fn get_logging(&self) -> LoggingArgs {
×
545
        match self.get_options() {
×
546
            None => LoggingArgs::default().clone(),
×
547
            Some(opt) => opt.get_logging().clone(),
×
548
        }
549
    }
×
550
}
551

552
impl CommonArgs {
553
    pub(crate) fn to_command(&self) -> Execute {
1✔
554
        Execute { options: self.clone() }
1✔
555
    }
1✔
556

557
    pub(crate) fn get_core_handler_url(&self, handler_url_path: &str) -> String {
4✔
558
        #[rustfmt::skip]
559
        let parts: Vec<String> = vec![
4✔
560
            self.url.with_suffix("/"),
4✔
561
            self.core.clone(),
4✔
562
            handler_url_path.with_prefix("/"),
4✔
563
        ];
564
        parts.concat()
4✔
565
    }
4✔
566

567
    pub(crate) fn get_update_url_with(&self, query_string_params: &str) -> String {
3✔
568
        let parts: Vec<String> =
3✔
569
            vec![self.get_core_handler_url("/update"), query_string_params.with_prefix("?")];
3✔
570
        parts.concat()
3✔
571
    }
3✔
572

573
    pub(crate) fn get_update_url(&self) -> String {
2✔
574
        self.get_update_url_with(EMPTY_STR)
2✔
575
    }
2✔
576

577
    pub(crate) fn get_core_admin_v2_url_with(&self, query_string_params: &str) -> String {
1✔
578
        let mut solr_uri = Url::parse(&self.url).unwrap();
1✔
579
        solr_uri.set_path("api/cores");
1✔
580
        let parts: Vec<String> = vec![solr_uri.to_string(), query_string_params.with_prefix("?")];
1✔
581
        parts.concat()
1✔
582
    }
1✔
583

584
    pub(crate) fn get_core_admin_v2_url(&self) -> String {
1✔
585
        self.get_core_admin_v2_url_with(EMPTY_STR)
1✔
586
    }
1✔
587

588
    pub(crate) fn get_logging(&self) -> &LoggingArgs {
4✔
589
        &self.logging
4✔
590
    }
4✔
591

592
    pub(crate) fn is_quiet(&self) -> bool {
3✔
593
        self.logging.log_level == LevelFilter::Off
3✔
594
    }
3✔
595
}
596

597
impl ParallelArgs {
598
    pub(crate) fn get_param(&self, separator: &str) -> String {
7✔
599
        self.params.as_ref().unwrap_or(&EMPTY_STRING).with_prefix(separator)
7✔
600
    }
7✔
601
}
602

603
impl LoggingArgs {
604
    pub(crate) fn is_quiet(&self) -> bool {
×
605
        self.log_level == LevelFilter::Off
×
606
    }
×
607
}
608

609
impl Default for LoggingArgs {
610
    fn default() -> Self {
×
611
        Self {
×
612
            log_level: LevelFilter::Off,
×
613
            log_mode: Default::default(),
×
614
            log_file_path: Default::default(),
×
615
            log_file_level: LevelFilter::Off,
×
616
        }
×
617
    }
×
618
}
619

620
impl Default for CommitMode {
621
    fn default() -> Self {
×
622
        CommitMode::Within { millis: 40_000 }
×
623
    }
×
624
}
625

626
impl FromStr for CommitMode {
627
    type Err = String;
628

629
    fn from_str(s: &str) -> Result<Self, Self::Err> {
3✔
630
        parse_commit_mode(s)
3✔
631
    }
3✔
632
}
633

634
impl CommitMode {
635
    pub(crate) fn as_param(&self, separator: &str) -> String {
3✔
636
        match self {
3✔
637
            CommitMode::Soft => separator.append("softCommit=true"),
1✔
638
            CommitMode::Hard => separator.append("commit=true"),
2✔
639
            CommitMode::Within { millis } => format!("{}commitWithin={}", separator, millis),
×
640
            _ => EMPTY_STRING,
×
641
        }
642
    }
3✔
643

644
    // pub (crate) fn as_xml(&self, separator: &str) -> String {
645
    //     match self {
646
    //         CommitMode::Soft => separator.append("<commit />"),
647
    //         CommitMode::Hard => separator.append("<commit />"),
648
    //         CommitMode::Within { millis } => {
649
    //             separator.append(format!("<commitWithin>{}</commitWithin>", millis).as_str())
650
    //         }
651
    //         _ => EMPTY_STRING,
652
    //     }
653
    // }
654
}
655

656
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
657
pub(crate) enum SortDirection {
658
    Asc,
659
    Desc,
660
}
661

662
#[derive(Parser, Clone, PartialEq, Eq, PartialOrd, Ord)]
663
pub(crate) struct SortField {
664
    pub field: String,
665
    pub direction: SortDirection,
666
}
667

668
impl fmt::Display for SortField {
669
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6✔
670
        write!(f, "{}%20{:?}", self.field, self.direction)
6✔
671
    }
6✔
672
}
673

674
impl fmt::Debug for SortField {
675
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
676
        write!(f, "{}:{:?}", self.field, self.direction)
×
677
    }
×
678
}
679

680
impl FromStr for SortField {
681
    type Err = String;
682

683
    fn from_str(s: &str) -> Result<Self, Self::Err> {
9✔
684
        if s.is_empty() {
9✔
685
            Err("missing value".to_string())
×
686
        } else {
687
            lazy_static! {
688
                static ref REO: Regex = Regex::new("^(\\w+)(([:\\s=])(asc|desc))?$").unwrap();
689
            }
690
            match REO.captures(s) {
9✔
691
                None => Err(s.to_string()),
×
692
                Some(cap) => {
9✔
693
                    let sort_dir = if cap.get_as_str(4) == "desc" {
9✔
694
                        SortDirection::Desc
3✔
695
                    } else {
696
                        SortDirection::Asc
6✔
697
                    };
698
                    let sort_field = cap.get_as_str(1).to_string();
9✔
699
                    Ok(SortField { field: sort_field, direction: sort_dir })
9✔
700
                }
701
            }
702
        }
703
    }
9✔
704
}
705

706
impl Generate {
707
    pub(crate) fn get_shells(&self) -> Vec<Shell> {
1✔
708
        let sh: Option<Shell> = if self.all { None } else { self.shell };
1✔
709
        match sh {
1✔
710
            Some(sh1) => vec![sh1],
×
711
            None => Shell::value_variants().to_vec(),
1✔
712
        }
713
    }
1✔
714
}
715

716
// #endregion
717

718
// #region Cli validation
719

720
pub(crate) trait Validation {
721
    fn validate(&self) -> Result<(), String> {
×
722
        Ok(())
×
723
    }
×
724
}
725

726
impl Validation for Backup {
727
    fn validate(&self) -> Result<(), String> {
×
728
        assert_dir_exists(&self.transfer.dir)
×
729
    }
×
730
}
731

732
impl Validation for Restore {
733
    fn validate(&self) -> Result<(), String> {
×
734
        assert_dir_exists(&self.transfer.dir)
×
735
    }
×
736
}
737

738
fn assert_dir_exists(dir: &Path) -> Result<(), String> {
×
739
    if !dir.exists() {
×
NEW
740
        Err(format!("Missing folder of the backuped archive files: {:?}", dir))
×
741
    } else {
742
        Ok(())
×
743
    }
744
}
×
745

746
// #endregion
747

748
// #endregion
749

750
#[cfg(test)]
751
pub(crate) mod shared {
752

753
    use crate::args::{Cli, Commands};
754
    use clap::Parser;
755

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

758
    impl Cli {
759
        pub(crate) fn mockup_from(argm: &[&str]) -> Commands {
2✔
760
            Self::parse_from(argm).arguments
2✔
761
        }
2✔
762

763
        pub(crate) fn mockup_and_panic(argm: &[&str]) -> Commands {
5✔
764
            let unknown = &["--unknown", "argument"];
5✔
765
            let combined = [argm, unknown].concat();
5✔
766
            let res = Self::try_parse_from(combined);
5✔
767
            res.unwrap().arguments
5✔
768
        }
5✔
769

770
        pub(crate) fn mockup_for_help(argm: &[&str]) {
4✔
771
            match Self::try_parse_from(argm) {
4✔
772
                Ok(ocli) => {
×
773
                    panic!("Ok parsing CLI arguments: {} -> {:?}", argm.join(" "), ocli)
×
774
                }
775
                Err(ef) => {
4✔
776
                    println!("Err parsing CLI arguments: {}", ef)
4✔
777
                }
778
            }
779
        }
4✔
780
    }
781
}
782

783
#[cfg(test)]
784
pub(crate) mod tests {
785

786
    // #region Mockup
787

788
    use super::shared::TEST_SELECT_FIELDS;
789
    use super::{Cli, Commands, CommitMode, parse_millis, parse_quantity};
790
    use clap::Parser;
791
    use clap_complete::Shell::Bash;
792
    use log::LevelFilter;
793
    use pretty_assertions::assert_eq;
794
    use std::path::PathBuf;
795

796
    impl Cli {
797
        pub(crate) fn mockup_args_backup() -> Commands {
2✔
798
            Self::parse_from(TEST_ARGS_BACKUP).arguments
2✔
799
        }
2✔
800

801
        pub(crate) fn mockup_args_restore() -> Commands {
3✔
802
            Self::parse_from(TEST_ARGS_RESTORE).arguments
3✔
803
        }
3✔
804

805
        pub(crate) fn mockup_args_commit() -> Commands {
1✔
806
            Self::parse_from(TEST_ARGS_COMMIT).arguments
1✔
807
        }
1✔
808
    }
809

810
    // #endregion Mockup
811

812
    // #region CLI Args
813

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

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

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

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

822
    const TEST_ARGS_BACKUP: &'static [&'static str] = &[
823
        "solrcopy",
824
        "backup",
825
        "--url",
826
        "http://solr-server.com:8983/solr",
827
        "--core",
828
        "demo",
829
        "--dir",
830
        "./tmp",
831
        "--query",
832
        "ownerId:173826 AND date:[{begin} TO {end}]",
833
        "--order",
834
        "date:asc,id:desc,vehiclePlate:asc",
835
        "--select",
836
        TEST_SELECT_FIELDS,
837
        "--between",
838
        "2020-05-01",
839
        "2020-05-04T11:12:13",
840
        "--archive-prefix",
841
        "zip_filename",
842
        "--skip",
843
        "3",
844
        "--limit",
845
        "42",
846
        "--num-docs",
847
        "5",
848
        "--archive-files",
849
        "6",
850
        "--delay-after",
851
        "5s",
852
        "--readers",
853
        "7",
854
        "--writers",
855
        "9",
856
        "--log-level",
857
        "debug",
858
        "--log-mode",
859
        "mixed",
860
        "--log-file-level",
861
        "debug",
862
        "--log-file-path",
863
        "/tmp/test.log",
864
    ];
865

866
    const TEST_ARGS_RESTORE: &'static [&'static str] = &[
867
        "solrcopy",
868
        "restore",
869
        "--url",
870
        "http://solr-server.com:8983/solr",
871
        "--dir",
872
        "./tmp",
873
        "--core",
874
        "target",
875
        "--search",
876
        "*.zip",
877
        "--flush",
878
        "soft",
879
        "--delay-per-request",
880
        "500ms",
881
        "--log-level",
882
        "debug",
883
    ];
884

885
    const TEST_ARGS_COMMIT: &'static [&'static str] = &[
886
        "solrcopy",
887
        "commit",
888
        "--url",
889
        "http://solr-server.com:8983/solr",
890
        "--core",
891
        "demo",
892
        "--log-level",
893
        "debug",
894
    ];
895

896
    const TEST_ARGS_DELETE: &'static [&'static str] = &[
897
        "solrcopy",
898
        "delete",
899
        "--url",
900
        "http://solr-server.com:8983/solr",
901
        "--core",
902
        "demo",
903
        "--query",
904
        "*:*",
905
        "--flush",
906
        "hard",
907
        "--log-level",
908
        "debug",
909
        "--log-file-level",
910
        "error",
911
    ];
912

913
    const TEST_ARGS_GENERATE: &'static [&'static str] =
914
        &["solrcopy", "generate", "--shell", "bash", "--output-dir", "target"];
915

916
    // #endregion
917

918
    // #region Tests
919

920
    #[test]
921
    fn check_params_backup() {
1✔
922
        let parsed = Cli::mockup_args_backup();
1✔
923
        match parsed {
1✔
924
            Commands::Backup(get) => {
1✔
925
                assert_eq!(get.options.url, TEST_ARGS_BACKUP[3]);
1✔
926
                assert_eq!(get.options.core, TEST_ARGS_BACKUP[5]);
1✔
927
                assert_eq!(get.transfer.dir.to_str().unwrap(), TEST_ARGS_BACKUP[7]);
1✔
928
                assert_eq!(get.query, Some(TEST_ARGS_BACKUP[9].to_string()));
1✔
929
                assert_eq!(get.skip, 3);
1✔
930
                assert_eq!(get.limit, Some(42));
1✔
931
                assert_eq!(get.num_docs, 5);
1✔
932
                assert_eq!(get.archive_files, 6);
1✔
933
                assert_eq!(get.transfer.readers, 7);
1✔
934
                assert_eq!(get.transfer.writers, 9);
1✔
935
                assert_eq!(get.options.get_logging().log_level, LevelFilter::Debug);
1✔
936
            }
937
            _ => panic!("command must be 'backup' !"),
×
938
        };
939
    }
1✔
940

941
    #[test]
942
    fn check_params_restore() {
1✔
943
        let parsed = Cli::mockup_args_restore();
1✔
944
        match parsed {
1✔
945
            Commands::Restore(put) => {
1✔
946
                assert_eq!(put.options.url, TEST_ARGS_RESTORE[3]);
1✔
947
                assert_eq!(put.transfer.dir.to_str().unwrap(), TEST_ARGS_RESTORE[5]);
1✔
948
                assert_eq!(put.options.core, TEST_ARGS_RESTORE[7]);
1✔
949
                assert_eq!(put.search.unwrap(), TEST_ARGS_RESTORE[9]);
1✔
950
                assert_eq!(put.flush, CommitMode::Soft);
1✔
951
                assert_eq!(put.flush.as_param("?"), "?softCommit=true");
1✔
952
                assert_eq!(put.options.get_logging().log_level, LevelFilter::Debug);
1✔
953
            }
954
            _ => panic!("command must be 'restore' !"),
×
955
        };
956
    }
1✔
957

958
    #[test]
959
    fn check_params_commit() {
1✔
960
        let parsed = Cli::mockup_args_commit();
1✔
961
        match parsed {
1✔
962
            Commands::Commit(put) => {
1✔
963
                assert_eq!(put.options.url, TEST_ARGS_COMMIT[3]);
1✔
964
                assert_eq!(put.options.core, TEST_ARGS_COMMIT[5]);
1✔
965
                assert_eq!(put.options.get_logging().log_level, LevelFilter::Debug);
1✔
966
            }
967
            _ => panic!("command must be 'commit' !"),
×
968
        };
969
    }
1✔
970

971
    #[test]
972
    fn check_params_delete() {
1✔
973
        let parsed = Cli::mockup_from(TEST_ARGS_DELETE);
1✔
974
        match parsed {
1✔
975
            Commands::Delete(res) => {
1✔
976
                assert_eq!(res.options.url, TEST_ARGS_DELETE[3]);
1✔
977
                assert_eq!(res.options.core, TEST_ARGS_DELETE[5]);
1✔
978
                assert_eq!(res.query, TEST_ARGS_DELETE[7]);
1✔
979
                assert_eq!(res.flush, CommitMode::Hard);
1✔
980
                let logs = res.options.get_logging();
1✔
981
                assert_eq!(logs.log_level, LevelFilter::Debug);
1✔
982
                assert_eq!(logs.log_file_level, LevelFilter::Error);
1✔
983
            }
984
            _ => panic!("command must be 'delete' !"),
×
985
        };
986
    }
1✔
987

988
    #[test]
989
    fn check_params_generate() {
1✔
990
        let parsed = Cli::mockup_from(TEST_ARGS_GENERATE);
1✔
991
        match parsed {
1✔
992
            Commands::Generate(res) => {
1✔
993
                assert_eq!(res.shell, Some(Bash));
1✔
994
                assert_eq!(res.output_dir, Some(PathBuf::from("target")));
1✔
995
            }
996
            _ => panic!("command must be 'generate' !"),
×
997
        };
998
    }
1✔
999

1000
    #[test]
1001
    fn check_params_help() {
1✔
1002
        Cli::mockup_for_help(TEST_ARGS_HELP);
1✔
1003
    }
1✔
1004

1005
    #[test]
1006
    fn check_params_version() {
1✔
1007
        Cli::mockup_for_help(TEST_ARGS_VERSION);
1✔
1008
    }
1✔
1009

1010
    #[test]
1011
    fn check_params_get_help() {
1✔
1012
        Cli::mockup_for_help(TEST_ARGS_HELP_BACKUP);
1✔
1013
    }
1✔
1014

1015
    #[test]
1016
    fn check_params_put_help() {
1✔
1017
        Cli::mockup_for_help(TEST_ARGS_HELP_RESTORE);
1✔
1018
    }
1✔
1019

1020
    #[test]
1021
    #[should_panic]
1022
    fn check_parse_unknown() {
1✔
1023
        Cli::mockup_and_panic(&["solrcopy"]);
1✔
1024
    }
1✔
1025

1026
    #[test]
1027
    #[should_panic]
1028
    fn check_parse_backup_unknown() {
1✔
1029
        Cli::mockup_and_panic(TEST_ARGS_BACKUP);
1✔
1030
    }
1✔
1031

1032
    #[test]
1033
    #[should_panic]
1034
    fn check_parse_restore_unknown() {
1✔
1035
        Cli::mockup_and_panic(TEST_ARGS_RESTORE);
1✔
1036
    }
1✔
1037

1038
    #[test]
1039
    #[should_panic]
1040
    fn check_parse_commit_unknown() {
1✔
1041
        Cli::mockup_and_panic(TEST_ARGS_COMMIT);
1✔
1042
    }
1✔
1043

1044
    #[test]
1045
    #[should_panic]
1046
    fn check_parse_delete_unknown() {
1✔
1047
        Cli::mockup_and_panic(TEST_ARGS_DELETE);
1✔
1048
    }
1✔
1049

1050
    #[test]
1051
    fn check_parse_quantity() {
1✔
1052
        assert_eq!(parse_quantity("3k"), Ok(3_000));
1✔
1053
        assert_eq!(parse_quantity("4 k"), Ok(4_000));
1✔
1054
        assert_eq!(parse_quantity("5kb"), Ok(5_000));
1✔
1055
        assert_eq!(parse_quantity("666m"), Ok(666_000_000));
1✔
1056
        assert_eq!(parse_quantity("777mb"), Ok(777_000_000));
1✔
1057
        assert_eq!(parse_quantity("888mb"), Ok(888_000_000));
1✔
1058
        assert_eq!(parse_quantity("999 mb"), Ok(999_000_000));
1✔
1059
    }
1✔
1060

1061
    #[test]
1062
    fn check_parse_millis() {
1✔
1063
        assert_eq!(parse_millis("3ms"), Ok(3));
1✔
1064
        assert_eq!(parse_millis("4 ms"), Ok(4));
1✔
1065
        assert_eq!(parse_millis("5s"), Ok(5_000));
1✔
1066
        assert_eq!(parse_millis("666s"), Ok(666_000));
1✔
1067
        assert_eq!(parse_millis("7m"), Ok(420_000));
1✔
1068
        assert_eq!(parse_millis("8min"), Ok(480_000));
1✔
1069
        assert_eq!(parse_millis("9 minutes"), Ok(540_000));
1✔
1070
        assert_eq!(parse_millis("10h"), Ok(36_000_000));
1✔
1071
    }
1✔
1072

1073
    // #endregion
1074
}
1075

1076
// 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