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

juarezr / solrcopy / 22975356339

11 Mar 2026 09:28PM UTC coverage: 81.975% (+0.2%) from 81.743%
22975356339

Pull #45

github

web-flow
Merge 32c30b775 into 4de9ef34c
Pull Request #45: Bug fixes and improved testing

276 of 310 new or added lines in 13 files covered. (89.03%)

8 existing lines in 5 files now uncovered.

1901 of 2319 relevant lines covered (81.97%)

3.61 hits per line

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

73.42
/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
    /// Get information about the Solr instance
54
    Info(Execute),
55
    /// Generates man page and completion scripts for different shells
56
    Generate(Generate),
57
}
58

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

154
    #[command(flatten)]
155
    pub options: CommonArgs,
156

157
    #[command(flatten)]
158
    pub transfer: ParallelArgs,
159
}
160

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

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

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

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

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

184
    #[command(flatten)]
185
    pub options: CommonArgs,
186

187
    #[command(flatten)]
188
    pub transfer: ParallelArgs,
189
}
190

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

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

204
    #[command(flatten)]
205
    pub options: CommonArgs,
206
}
207

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

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

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

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

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

233
// #endregion
234

235
// #region Cli common
236

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

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

247
    #[command(flatten)]
248
    pub logging: LoggingArgs,
249
}
250

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

354
pub(crate) const SOLR_COPY_DIR: &str = "SOLR_COPY_DIR";
355
pub(crate) const SOLR_COPY_URL: &str = "SOLR_COPY_URL";
356

357
// #endregion
358

359
// #region Param parsing
360

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

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

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

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

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

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

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

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

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

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

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

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

523
// #endregion
524

525
// #region Cli impl
526

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

536
    pub(crate) fn get_options(&self) -> Option<&CommonArgs> {
×
537
        match &self {
×
538
            Self::Backup(get) => Some(&get.options),
×
539
            Self::Restore(put) => Some(&put.options),
×
540
            Self::Commit(com) => Some(&com.options),
×
541
            Self::Delete(del) => Some(&del.options),
×
NEW
542
            Self::Create(cre) => Some(&cre.options),
×
NEW
543
            Self::Info(inf) => Some(&inf.options),
×
UNCOV
544
            _ => None,
×
545
        }
546
    }
×
547

548
    pub(crate) fn get_logging(&self) -> LoggingArgs {
×
549
        match self.get_options() {
×
550
            None => LoggingArgs::default().clone(),
×
551
            Some(opt) => opt.get_logging().clone(),
×
552
        }
553
    }
×
554
}
555

556
impl CommonArgs {
557
    pub(crate) fn to_command(&self) -> Execute {
1✔
558
        Execute { options: self.clone() }
1✔
559
    }
1✔
560

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

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

577
    pub(crate) fn get_update_url(&self) -> String {
2✔
578
        self.get_update_url_with(EMPTY_STR)
2✔
579
    }
2✔
580

581
    pub(crate) fn get_url_from(&self, path: &str) -> String {
1✔
582
        let mut solr_uri = Url::parse(&self.url).unwrap();
1✔
583
        solr_uri.set_path(path);
1✔
584
        solr_uri.to_string()
1✔
585
    }
1✔
586

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

715
// #endregion
716

717
// #region Cli validation
718

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

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

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

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

745
// #endregion
746

747
// #endregion
748

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

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

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

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

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

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

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

785
    // #region Mockup
786

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

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

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

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

809
    // #endregion Mockup
810

811
    // #region CLI Args
812

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

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

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

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

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

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

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

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

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

915
    // #endregion
916

917
    // #region Tests
918

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1072
    // #endregion
1073
}
1074

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