Skip to main content

common/
cli.rs

1//! Common CLI arguments shared by every RCP binary.
2//!
3//! Each binary flattens [`CommonArgs`] into its own clap struct via
4//! `#[command(flatten)]`. Tool-specific arguments live in the binary itself.
5//!
6//! Fields intentionally NOT in this struct, so each binary can document them
7//! accurately:
8//! - `chunk_size` — rcp/rcpd parse as `bytesize::ByteSize` (e.g. "16MiB"),
9//!   others as bare `u64`.
10//! - `summary` — rcpd streams results to the master and never prints a summary.
11//! - `quiet` — rcmp's `--quiet` also suppresses stdout differences (not just
12//!   error output), so its help text differs from the other tools.
13
14/// Shared help for the file-like operation concurrency setting.
15pub const FILES_IN_FLIGHT_HELP: &str = "Maximum concurrent file-like operations. Local automatic defaults resolve from available CPU parallelism on this host, with a floor of 4. Use a positive integer for a finite ceiling or 'unlimited' to remove the user ceiling; lower internal safety ceilings may still apply. This applies to file copy, link, comparison, removal, chmod, and generation work.";
16
17#[derive(Debug, Clone, clap::Args)]
18pub struct CommonArgs {
19    // Progress & output
20    /// Show progress
21    #[arg(long, help_heading = "Progress & output")]
22    pub progress: bool,
23    /// Set the type of progress display
24    ///
25    /// If specified, --progress flag is implied.
26    #[arg(long, value_name = "TYPE", help_heading = "Progress & output")]
27    pub progress_type: Option<crate::ProgressType>,
28    /// Set delay between progress updates
29    ///
30    /// Default is 200ms for interactive mode (`ProgressBar`) and 10s for non-interactive
31    /// mode (`TextUpdates`). If specified, --progress flag is implied. Accepts
32    /// human-readable durations like "200ms", "10s", "5min".
33    #[arg(long, value_name = "DELAY", help_heading = "Progress & output")]
34    pub progress_delay: Option<String>,
35    /// Verbose level (implies "summary"): -v INFO / -vv DEBUG / -vvv TRACE (default: ERROR)
36    #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, help_heading = "Progress & output")]
37    pub verbose: u8,
38    // Performance & throttling
39    /// Throttle the number of operations per second (0 = no throttle)
40    #[arg(
41        long,
42        default_value = "0",
43        value_name = "N",
44        help_heading = "Performance & throttling"
45    )]
46    pub ops_throttle: usize,
47    /// Limit I/O operations per second (0 = no throttle)
48    ///
49    /// Requires --chunk-size to calculate I/O operations per file: ((`file_size` - 1) / `chunk_size`) + 1
50    #[arg(
51        long,
52        default_value = "0",
53        value_name = "N",
54        help_heading = "Performance & throttling"
55    )]
56    pub iops_throttle: usize,
57    /// Maximum concurrent file-like operations.
58    #[arg(long, value_name = "N|unlimited", value_parser = parse_files_in_flight_limit,
59          help = FILES_IN_FLIGHT_HELP,
60          help_heading = "Performance & throttling",
61          conflicts_with = "max_open_files")]
62    pub max_files_in_flight: Option<crate::ConcurrencyLimit>,
63    #[arg(
64        long,
65        value_name = "N",
66        hide = true,
67        conflicts_with = "max_files_in_flight"
68    )]
69    pub max_open_files: Option<usize>,
70    // Advanced settings
71    /// Number of worker threads (0 = number of CPU cores)
72    #[arg(
73        long,
74        default_value = "0",
75        value_name = "N",
76        help_heading = "Advanced settings"
77    )]
78    pub max_workers: usize,
79    /// Number of blocking worker threads (0 = Tokio default of 512)
80    #[arg(
81        long,
82        default_value = "0",
83        value_name = "N",
84        help_heading = "Advanced settings"
85    )]
86    pub max_blocking_threads: usize,
87    // Congestion control (experimental, opt-in)
88    /// Enable adaptive metadata-ops throttling (latency-ratio controller)
89    #[arg(long, help_heading = "Congestion control")]
90    pub auto_meta_throttle: bool,
91    /// Initial concurrency window for adaptive metadata throttle
92    #[arg(
93        long,
94        default_value = "1",
95        value_name = "N",
96        help_heading = "Congestion control"
97    )]
98    pub auto_meta_initial_cwnd: u32,
99    /// Minimum concurrency window (floor below which cwnd cannot shrink)
100    #[arg(
101        long,
102        default_value = "1",
103        value_name = "N",
104        help_heading = "Congestion control (advanced)"
105    )]
106    pub auto_meta_min_cwnd: u32,
107    /// Maximum concurrency window (ceiling on adaptive growth)
108    #[arg(
109        long,
110        default_value = "4096",
111        value_name = "N",
112        help_heading = "Congestion control"
113    )]
114    pub auto_meta_max_cwnd: u32,
115    /// Latency ratio below which cwnd grows (current / baseline).
116    /// Default 1.3, sized to sit just below the steady-state p10/p50
117    /// inter-quantile spread of typical metadata syscalls so the
118    /// controller climbs only when the spread compresses. `alpha` may
119    /// be set below 1.0 in passive matched mode (grow only when recent
120    /// is meaningfully faster than baseline). The natural scale depends
121    /// on the percentile pair: matched percentiles produce a steady-
122    /// state ratio of 1.0; cross percentiles produce a ratio above 1.0
123    /// set by the inter-quantile spread of the latency distribution.
124    #[arg(
125        long,
126        default_value = "1.3",
127        value_name = "F",
128        help_heading = "Congestion control (advanced)"
129    )]
130    pub auto_meta_alpha: f64,
131    /// Latency ratio above which cwnd shrinks. Default 1.8, sized to
132    /// sit above the steady-state p10/p50 spread so only genuine
133    /// queueing-driven tail growth triggers a backoff.
134    #[arg(
135        long,
136        default_value = "1.8",
137        value_name = "F",
138        help_heading = "Congestion control (advanced)"
139    )]
140    pub auto_meta_beta: f64,
141    /// Percentile (in `[0.0, 1.0)`) applied to the long-horizon window
142    /// to derive the baseline statistic. Default 0.1 (p10): paired with
143    /// the p50 current percentile this gives a cross-percentile ratio
144    /// whose steady-state level tracks the lower-half spread of the
145    /// per-syscall latency distribution and rises with queueing. With
146    /// matched percentiles (`baseline == current`) the steady-state
147    /// ratio sits near 1.0 instead.
148    #[arg(
149        long,
150        default_value = "0.1",
151        value_name = "F",
152        help_heading = "Congestion control (advanced)"
153    )]
154    pub auto_meta_baseline_percentile: f64,
155    /// Percentile (in `[0.0, 1.0)`) applied to the short-horizon window
156    /// to derive the current statistic. Default 0.5 (p50). Must be
157    /// `>= baseline percentile`. See `--auto-meta-baseline-percentile`.
158    #[arg(
159        long,
160        default_value = "0.5",
161        value_name = "F",
162        help_heading = "Congestion control (advanced)"
163    )]
164    pub auto_meta_current_percentile: f64,
165    /// How much to grow cwnd on each under-shoot tick
166    #[arg(
167        long,
168        default_value = "1",
169        value_name = "N",
170        help_heading = "Congestion control (advanced)"
171    )]
172    pub auto_meta_increase_step: u32,
173    /// How much to shrink cwnd on each over-shoot tick
174    #[arg(
175        long,
176        default_value = "1",
177        value_name = "N",
178        help_heading = "Congestion control (advanced)"
179    )]
180    pub auto_meta_decrease_step: u32,
181    /// Long-horizon sample window (e.g. "10s"). Drives the baseline
182    /// percentile; samples older than this are evicted on every tick.
183    #[arg(
184        long,
185        default_value = "10s",
186        value_name = "DUR",
187        help_heading = "Congestion control (advanced)"
188    )]
189    pub auto_meta_long_window: humantime::Duration,
190    /// Short-horizon sample window (e.g. "1s"). Drives the current-state
191    /// percentile; must be strictly less than `--auto-meta-long-window`.
192    #[arg(
193        long,
194        default_value = "1s",
195        value_name = "DUR",
196        help_heading = "Congestion control (advanced)"
197    )]
198    pub auto_meta_short_window: humantime::Duration,
199    /// Control-loop tick interval (e.g. "50ms")
200    #[arg(
201        long,
202        default_value = "50ms",
203        value_name = "DUR",
204        help_heading = "Congestion control (advanced)"
205    )]
206    pub auto_meta_tick_interval: humantime::Duration,
207    /// Enable in-memory HDR latency histograms per (side, op). Implies
208    /// `--auto-meta-throttle`. Adds a distribution panel beneath the
209    /// existing one-line-per-controller summary in the progress display.
210    #[arg(long, help_heading = "Congestion control")]
211    pub auto_meta_histogram: bool,
212    /// Write a binary log of per-(side, op) HDR histograms to the given
213    /// path. The file is truncated if it already exists — rename or move
214    /// logs you want to keep across runs. Format documented in
215    /// `docs/congestion_control.md`. Implies `--auto-meta-histogram` and
216    /// `--auto-meta-throttle`.
217    #[arg(long, value_name = "PATH", help_heading = "Congestion control")]
218    pub auto_meta_histogram_log: Option<std::path::PathBuf>,
219    /// Snapshot cadence for the histogram logger (e.g. "1s"). Drives both
220    /// the panel refresh rate and the log-file record interval. Range
221    /// `[100ms, 60s]`.
222    #[arg(
223        long,
224        default_value = "1s",
225        value_name = "DUR",
226        help_heading = "Congestion control"
227    )]
228    pub auto_meta_histogram_interval: humantime::Duration,
229}
230
231impl CommonArgs {
232    #[must_use]
233    pub fn resolve_files_in_flight(&self) -> crate::ResolvedFilesInFlight {
234        match (self.max_files_in_flight, self.max_open_files) {
235            (Some(value), None) => crate::ResolvedFilesInFlight::explicit_limit(value),
236            (None, Some(value)) => crate::ResolvedFilesInFlight::legacy(value),
237            (None, None) => crate::ResolvedFilesInFlight::automatic(),
238            (Some(_), Some(_)) => unreachable!("clap rejects conflicting file limits"),
239        }
240    }
241    /// Build a [`crate::OutputConfig`]. `quiet` and `print_summary` are
242    /// supplied by the caller (each binary owns its own `--quiet` and
243    /// `--summary` flags so it can document binary-specific semantics).
244    #[must_use]
245    pub fn output_config(&self, quiet: bool, print_summary: bool) -> crate::OutputConfig {
246        crate::OutputConfig {
247            quiet,
248            verbose: self.verbose,
249            print_summary,
250            ..Default::default()
251        }
252    }
253    /// Build a [`crate::RuntimeConfig`] from these args.
254    #[must_use]
255    pub fn runtime_config(&self) -> crate::RuntimeConfig {
256        crate::RuntimeConfig {
257            max_workers: self.max_workers,
258            max_blocking_threads: self.max_blocking_threads,
259        }
260    }
261    /// Build a [`crate::ThrottleConfig`]. `chunk_size` has different parser types per binary.
262    #[must_use]
263    pub fn throttle_config(
264        &self,
265        files_in_flight: crate::ResolvedFilesInFlight,
266        chunk_size: u64,
267    ) -> crate::ThrottleConfig {
268        let auto_meta_implied = self.auto_meta_throttle
269            || self.auto_meta_histogram
270            || self.auto_meta_histogram_log.is_some();
271        let auto_meta = auto_meta_implied.then(|| crate::AutoMetaThrottleConfig {
272            initial_cwnd: self.auto_meta_initial_cwnd,
273            min_cwnd: self.auto_meta_min_cwnd,
274            max_cwnd: self.auto_meta_max_cwnd,
275            alpha: self.auto_meta_alpha,
276            beta: self.auto_meta_beta,
277            increase_step: self.auto_meta_increase_step,
278            decrease_step: self.auto_meta_decrease_step,
279            baseline_percentile: self.auto_meta_baseline_percentile,
280            current_percentile: self.auto_meta_current_percentile,
281            long_window: self.auto_meta_long_window.into(),
282            short_window: self.auto_meta_short_window.into(),
283            tick_interval: self.auto_meta_tick_interval.into(),
284        });
285        crate::ThrottleConfig {
286            files_in_flight,
287            apply_files_in_flight: true,
288            ops_throttle: self.ops_throttle,
289            iops_throttle: self.iops_throttle,
290            chunk_size,
291            auto_meta,
292            histogram_enabled: self.auto_meta_histogram || self.auto_meta_histogram_log.is_some(),
293            histogram_log_path: self.auto_meta_histogram_log.clone(),
294            histogram_interval: self.auto_meta_histogram_interval.into(),
295        }
296    }
297    /// Returns true if any progress-related flag was set.
298    ///
299    /// `--auto-meta-histogram` implies progress because its sole purpose is
300    /// to render a live distribution panel. `--auto-meta-histogram-log` does
301    /// NOT imply progress — it writes to a file regardless of progress mode,
302    /// and forcing a display would be worse UX for users who only want the
303    /// file.
304    #[must_use]
305    pub fn progress_requested(&self) -> bool {
306        self.progress
307            || self.progress_type.is_some()
308            || self.progress_delay.is_some()
309            || self.auto_meta_histogram
310    }
311
312    /// Build user-facing [`crate::ProgressSettings`] when any progress flag was
313    /// set, else `None`. `kind` selects the tool-specific printer. For `rcp`'s
314    /// remote-master and `rcpd`'s remote progress modes, build `ProgressSettings`
315    /// directly instead of using this helper.
316    #[must_use]
317    pub fn user_progress_settings(
318        &self,
319        kind: crate::progress::LocalProgressKind,
320    ) -> Option<crate::ProgressSettings> {
321        if !self.progress_requested() {
322            return None;
323        }
324        Some(crate::ProgressSettings {
325            progress_type: crate::GeneralProgressType::User {
326                progress_type: self.progress_type.unwrap_or_default(),
327                kind,
328            },
329            progress_delay: self.progress_delay.clone(),
330        })
331    }
332}
333
334pub fn parse_positive_usize(value: &str) -> Result<std::num::NonZeroUsize, String> {
335    let value = value.parse::<usize>().map_err(|error| error.to_string())?;
336    std::num::NonZeroUsize::new(value).ok_or_else(|| "value must be at least 1".to_string())
337}
338
339pub fn parse_files_in_flight_limit(value: &str) -> Result<crate::ConcurrencyLimit, String> {
340    if value == "unlimited" {
341        return Ok(crate::ConcurrencyLimit::Unlimited);
342    }
343    parse_positive_usize(value).map(crate::ConcurrencyLimit::Limited)
344}
345
346pub fn parse_positive_u64(value: &str) -> Result<u64, String> {
347    let value = value.parse::<u64>().map_err(|error| error.to_string())?;
348    (value > 0)
349        .then_some(value)
350        .ok_or_else(|| "value must be at least 1".to_string())
351}
352
353#[cfg(test)]
354mod implies_tests {
355    use super::*;
356    use clap::Parser;
357    use std::num::NonZeroUsize;
358
359    #[derive(Debug, Parser)]
360    struct TestCli {
361        #[command(flatten)]
362        common: CommonArgs,
363    }
364
365    #[test]
366    fn max_files_in_flight_resolves_cli_sources() {
367        let explicit = TestCli::try_parse_from(["test", "--max-files-in-flight=1"])
368            .expect("positive max-files-in-flight must parse")
369            .common
370            .resolve_files_in_flight();
371        assert_eq!(
372            explicit,
373            crate::ResolvedFilesInFlight::explicit(NonZeroUsize::new(1).unwrap())
374        );
375        let explicit_unlimited =
376            TestCli::try_parse_from(["test", "--max-files-in-flight=unlimited"])
377                .expect("explicit unlimited max-files-in-flight must parse")
378                .common
379                .resolve_files_in_flight();
380        assert_eq!(
381            explicit_unlimited,
382            crate::ResolvedFilesInFlight::unlimited()
383        );
384        let automatic = TestCli::try_parse_from(["test"])
385            .expect("omitted max-files-in-flight must parse")
386            .common
387            .resolve_files_in_flight();
388        assert_eq!(automatic.source(), crate::FilesInFlightSource::Automatic);
389        let legacy_zero = TestCli::try_parse_from(["test", "--max-open-files=0"])
390            .expect("legacy zero must parse")
391            .common
392            .resolve_files_in_flight();
393        assert_eq!(legacy_zero.limit(), crate::ConcurrencyLimit::Unlimited);
394        assert_eq!(
395            legacy_zero.source(),
396            crate::FilesInFlightSource::DeprecatedMaxOpenFiles
397        );
398        let legacy_positive = TestCli::try_parse_from(["test", "--max-open-files=7"])
399            .expect("positive legacy value must parse")
400            .common
401            .resolve_files_in_flight();
402        assert_eq!(legacy_positive, crate::ResolvedFilesInFlight::legacy(7));
403    }
404
405    #[test]
406    fn max_files_in_flight_rejects_invalid_and_conflicting_values() {
407        let zero = TestCli::try_parse_from(["test", "--max-files-in-flight=0"])
408            .expect_err("zero max-files-in-flight must be rejected");
409        assert!(zero.to_string().contains("value must be at least 1"));
410        assert!(TestCli::try_parse_from(["test", "--max-files-in-flight=nope"]).is_err());
411        assert!(
412            TestCli::try_parse_from(["test", "--max-files-in-flight=1", "--max-open-files=1",])
413                .is_err()
414        );
415    }
416
417    #[test]
418    fn max_files_in_flight_help_stays_local_to_shared_tools() {
419        let help = TestCli::try_parse_from(["test", "--help"])
420            .expect_err("help must stop clap parsing")
421            .to_string();
422        assert!(help.contains(
423            "Local automatic defaults resolve from available CPU parallelism on this host"
424        ));
425        assert!(
426            !help.contains("source rcpd"),
427            "shared CLI help must not describe remote negotiation: {help}"
428        );
429    }
430
431    #[test]
432    fn long_help_only_advertises_max_files_in_flight() {
433        let help = TestCli::try_parse_from(["test", "--help"])
434            .expect_err("help must stop clap parsing")
435            .to_string();
436        assert!(help.contains("--max-files-in-flight"));
437        assert!(!help.contains("--max-open-files"));
438    }
439
440    #[test]
441    fn auto_meta_histogram_implies_throttle_at_cli() {
442        let cli = TestCli::parse_from(["test", "--auto-meta-histogram"]);
443        let throttle = cli
444            .common
445            .throttle_config(cli.common.resolve_files_in_flight(), 0);
446        assert!(
447            throttle.auto_meta.is_some(),
448            "histogram flag must imply auto_meta"
449        );
450        assert!(throttle.histogram_enabled);
451    }
452
453    #[test]
454    fn auto_meta_histogram_log_implies_throttle_at_cli() {
455        let cli = TestCli::parse_from(["test", "--auto-meta-histogram-log", "/tmp/x.hdr"]);
456        let throttle = cli
457            .common
458            .throttle_config(cli.common.resolve_files_in_flight(), 0);
459        assert!(
460            throttle.auto_meta.is_some(),
461            "histogram-log flag must imply auto_meta"
462        );
463        assert!(throttle.histogram_log_path.is_some());
464    }
465
466    #[test]
467    fn no_auto_meta_flags_means_no_throttle() {
468        let cli = TestCli::parse_from(["test"]);
469        let throttle = cli
470            .common
471            .throttle_config(cli.common.resolve_files_in_flight(), 0);
472        assert!(throttle.auto_meta.is_none());
473        assert!(!throttle.histogram_enabled);
474    }
475
476    /// `--auto-meta-histogram` (panel-only) sets `auto_meta_throttle = false`.
477    ///
478    /// The rcp binary uses this distinction when building `RcpdConfig`: it
479    /// gates `RcpdConfig::auto_meta` on `auto_meta_throttle || histogram_log
480    /// .is_some()`, so that the panel-only flag does NOT silently enable the
481    /// throttle pipeline on remote daemons.  This test pins that distinction
482    /// at the `CommonArgs` level so a future refactor cannot accidentally
483    /// collapse the two flags.
484    #[test]
485    fn panel_flag_does_not_set_explicit_throttle_field() {
486        let cli = TestCli::parse_from(["test", "--auto-meta-histogram"]);
487        // `throttle_config()` returns `auto_meta = Some(...)` because the
488        // panel needs the throttle pipeline locally — that's intentional.
489        assert!(
490            cli.common
491                .throttle_config(cli.common.resolve_files_in_flight(), 0)
492                .auto_meta
493                .is_some()
494        );
495        // But the *explicit* throttle field must stay false so the rcp binary
496        // can distinguish "panel only" from "user explicitly asked for throttle".
497        assert!(
498            !cli.common.auto_meta_throttle,
499            "--auto-meta-histogram must not set auto_meta_throttle"
500        );
501        // And no log path either.
502        assert!(cli.common.auto_meta_histogram_log.is_none());
503    }
504
505    #[test]
506    fn auto_meta_histogram_implies_progress() {
507        let cli = TestCli::parse_from(["test", "--auto-meta-histogram"]);
508        assert!(
509            cli.common.progress_requested(),
510            "--auto-meta-histogram alone must imply progress so the panel actually renders",
511        );
512    }
513
514    #[test]
515    fn auto_meta_histogram_log_does_not_imply_progress() {
516        // the log file writes regardless of progress; user can opt in to
517        // progress separately. don't force it on them.
518        let cli = TestCli::parse_from(["test", "--auto-meta-histogram-log", "/tmp/x.hdr"]);
519        assert!(
520            !cli.common.progress_requested(),
521            "--auto-meta-histogram-log alone should NOT imply progress",
522        );
523    }
524}