Skip to main content

common/
config.rs

1//! Configuration types for runtime and execution settings
2
3use serde::{Deserialize, Serialize};
4use std::num::NonZeroUsize;
5
6pub const MIN_DEFAULT_FILES_IN_FLIGHT: usize = 4;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
9pub enum ConcurrencyLimit {
10    Unlimited,
11    Limited(std::num::NonZeroUsize),
12}
13
14impl ConcurrencyLimit {
15    #[must_use]
16    pub fn meet(self, other: Self) -> Self {
17        match (self, other) {
18            (Self::Unlimited, limit) | (limit, Self::Unlimited) => limit,
19            (Self::Limited(left), Self::Limited(right)) => {
20                Self::Limited(std::cmp::min(left, right))
21            }
22        }
23    }
24}
25
26impl std::fmt::Display for ConcurrencyLimit {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Unlimited => formatter.write_str("unlimited"),
30            Self::Limited(value) => value.fmt(formatter),
31        }
32    }
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub enum FilesInFlightSource {
37    Automatic,
38    Explicit,
39    DeprecatedMaxOpenFiles,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct ResolvedFilesInFlight {
44    limit: ConcurrencyLimit,
45    source: FilesInFlightSource,
46    warn_deprecated_max_open_files: bool,
47}
48
49fn default_files_in_flight_from(available: Option<NonZeroUsize>) -> NonZeroUsize {
50    let available = available.map_or(1, NonZeroUsize::get);
51    NonZeroUsize::new(available.max(MIN_DEFAULT_FILES_IN_FLIGHT))
52        .expect("the minimum files-in-flight value is nonzero")
53}
54
55fn default_files_in_flight() -> NonZeroUsize {
56    default_files_in_flight_from(std::thread::available_parallelism().ok())
57}
58
59impl ResolvedFilesInFlight {
60    pub fn automatic() -> Self {
61        Self::automatic_with(default_files_in_flight())
62    }
63
64    pub const fn automatic_with(value: NonZeroUsize) -> Self {
65        Self {
66            limit: ConcurrencyLimit::Limited(value),
67            source: FilesInFlightSource::Automatic,
68            warn_deprecated_max_open_files: false,
69        }
70    }
71
72    pub const fn explicit(value: NonZeroUsize) -> Self {
73        Self::explicit_limit(ConcurrencyLimit::Limited(value))
74    }
75
76    pub const fn explicit_limit(limit: ConcurrencyLimit) -> Self {
77        Self {
78            limit,
79            source: FilesInFlightSource::Explicit,
80            warn_deprecated_max_open_files: false,
81        }
82    }
83
84    pub const fn unlimited() -> Self {
85        Self::explicit_limit(ConcurrencyLimit::Unlimited)
86    }
87
88    pub fn legacy(value: usize) -> Self {
89        Self::legacy_with_warning(value, true)
90    }
91
92    pub fn forwarded_legacy(value: usize) -> Self {
93        Self::legacy_with_warning(value, false)
94    }
95
96    fn legacy_with_warning(value: usize, warn_deprecated_max_open_files: bool) -> Self {
97        Self {
98            limit: NonZeroUsize::new(value)
99                .map_or(ConcurrencyLimit::Unlimited, ConcurrencyLimit::Limited),
100            source: FilesInFlightSource::DeprecatedMaxOpenFiles,
101            warn_deprecated_max_open_files,
102        }
103    }
104
105    pub const fn limit(self) -> ConcurrencyLimit {
106        self.limit
107    }
108
109    pub const fn source(self) -> FilesInFlightSource {
110        self.source
111    }
112}
113
114/// Dry-run mode for previewing operations without executing them
115#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
116pub enum DryRunMode {
117    /// show only what would be copied/linked/removed
118    #[value(name = "brief")]
119    Brief,
120    /// also show skipped files
121    #[value(name = "all")]
122    All,
123    /// show skipped files with the pattern that caused the skip
124    #[value(name = "explain")]
125    Explain,
126}
127
128/// Runtime configuration for tokio and thread pools
129#[derive(Debug, Clone, Copy, Default)]
130pub struct RuntimeConfig {
131    /// Number of worker threads (0 = number of CPU cores)
132    pub max_workers: usize,
133    /// Number of blocking threads (0 = tokio default of 512)
134    pub max_blocking_threads: usize,
135}
136
137/// Tunables for the adaptive metadata-throttle control loop.
138///
139/// Populated from CLI flags when `--auto-meta-throttle` is set; otherwise
140/// this field is `None` on [`ThrottleConfig`] and the control loop is not
141/// spawned. Serializable so that `rcp` can propagate the settings to
142/// remote `rcpd` processes over the control channel.
143#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
144pub struct AutoMetaThrottleConfig {
145    pub initial_cwnd: u32,
146    pub min_cwnd: u32,
147    pub max_cwnd: u32,
148    pub alpha: f64,
149    pub beta: f64,
150    pub increase_step: u32,
151    pub decrease_step: u32,
152    /// Percentile (in `[0.0, 1.0)`) applied to the long-horizon window
153    /// to derive the baseline statistic. Must be `<= current_percentile`.
154    pub baseline_percentile: f64,
155    /// Percentile (in `[0.0, 1.0)`) applied to the short-horizon window
156    /// to derive the current statistic. Must be `>= baseline_percentile`.
157    pub current_percentile: f64,
158    /// Long-horizon window age. Drives the baseline statistic.
159    pub long_window: std::time::Duration,
160    /// Short-horizon window age. Drives the current statistic.
161    pub short_window: std::time::Duration,
162    pub tick_interval: std::time::Duration,
163}
164
165/// Throttling configuration for resource control
166#[derive(Debug, Clone)]
167pub struct ThrottleConfig {
168    /// User-selected ceiling for file-like work, including its source for compatibility warnings.
169    pub files_in_flight: ResolvedFilesInFlight,
170    /// Whether this process performs file work governed by `files_in_flight`.
171    pub apply_files_in_flight: bool,
172    /// Operations per second throttle (0 = no throttle)
173    pub ops_throttle: usize,
174    /// I/O operations per second throttle (0 = no throttle)
175    pub iops_throttle: usize,
176    /// Chunk size for I/O operations (bytes)
177    pub chunk_size: u64,
178    /// Adaptive metadata-ops throttle, if enabled via `--auto-meta-throttle`.
179    pub auto_meta: Option<AutoMetaThrottleConfig>,
180    /// Enables in-memory HDR histograms for the auto-meta probes (live
181    /// display panel + driver for the optional log file). Implied by
182    /// `histogram_log_path.is_some()`.
183    pub histogram_enabled: bool,
184    /// When set, the auto-meta histogram logger appends a binary record
185    /// stream to this path on each snapshot tick. See
186    /// `docs/congestion_control.md` for the format.
187    pub histogram_log_path: Option<std::path::PathBuf>,
188    /// Snapshot cadence for the histogram logger. Drives both the live
189    /// panel and the log file. Range `[100ms, 60s]`.
190    pub histogram_interval: std::time::Duration,
191}
192
193impl Default for ThrottleConfig {
194    fn default() -> Self {
195        Self {
196            files_in_flight: ResolvedFilesInFlight::automatic(),
197            apply_files_in_flight: true,
198            ops_throttle: 0,
199            iops_throttle: 0,
200            chunk_size: 0,
201            auto_meta: None,
202            histogram_enabled: false,
203            histogram_log_path: None,
204            histogram_interval: std::time::Duration::from_secs(1),
205        }
206    }
207}
208
209/// Minimum static `--ops-throttle` when `--auto-meta-throttle` is on.
210///
211/// Auto-meta forces the ops-throttle to a fixed 100ms replenish interval
212/// so the adapter's `Decision::rate_per_sec` → tokens-per-interval
213/// conversion is always correct. That means the per-interval token count
214/// is `rate / 10` and rounds to zero for rates below 10 ops/sec — which
215/// would silently pause the gate after the initial drain. Reject the
216/// combination explicitly so the user hits a clear error instead of
217/// mysterious quiescence.
218pub const AUTO_META_MIN_OPS_THROTTLE: usize = 10;
219
220impl ThrottleConfig {
221    #[must_use]
222    pub fn deprecated_max_open_files_warning(&self) -> Option<&'static str> {
223        if !self.files_in_flight.warn_deprecated_max_open_files {
224            return None;
225        }
226        match self.files_in_flight.limit() {
227            ConcurrencyLimit::Unlimited => Some(
228                "--max-open-files=0 is deprecated; use --max-files-in-flight=unlimited instead. It removes the user ceiling but no longer disables descriptor safety.",
229            ),
230            ConcurrencyLimit::Limited(_) => {
231                Some("--max-open-files is deprecated; use --max-files-in-flight instead.")
232            }
233        }
234    }
235    /// Validate configuration and return errors if invalid
236    pub fn validate(&self) -> Result<(), String> {
237        if self.iops_throttle > 0 && self.chunk_size == 0 {
238            return Err("chunk_size must be specified when using iops_throttle".to_string());
239        }
240        if let Some(auto) = &self.auto_meta {
241            if auto.max_cwnd == 0 {
242                return Err("auto-meta-max-cwnd must be > 0".to_string());
243            }
244            if auto.min_cwnd == 0 {
245                return Err("auto-meta-min-cwnd must be >= 1".to_string());
246            }
247            if auto.min_cwnd > auto.max_cwnd {
248                return Err("auto-meta-min-cwnd must be <= auto-meta-max-cwnd".to_string());
249            }
250            if !(0.0..1.0).contains(&auto.baseline_percentile) {
251                return Err("auto-meta-baseline-percentile must be in [0.0, 1.0)".to_string());
252            }
253            if !(0.0..1.0).contains(&auto.current_percentile) {
254                return Err("auto-meta-current-percentile must be in [0.0, 1.0)".to_string());
255            }
256            if auto.baseline_percentile > auto.current_percentile {
257                return Err(
258                    "auto-meta-baseline-percentile must be <= auto-meta-current-percentile"
259                        .to_string(),
260                );
261            }
262            // alpha and beta gate the ratio = current / baseline:
263            // ratio < alpha → grow, ratio > beta → shrink. The only
264            // hard invariant is `0 < alpha < beta`. The "natural" placement
265            // of alpha and beta relative to 1.0 depends on the percentile
266            // pair: matched percentiles produce a steady-state ratio
267            // near 1.0, while cross percentiles produce a steady-state
268            // ratio above 1.0 set by the inter-quantile spread of the
269            // latency distribution. Either case may want alpha below or
270            // above 1.0 depending on whether the operator wants the
271            // controller to actively probe past the knee or sit passively
272            // until queueing crosses the beta threshold.
273            if !auto.alpha.is_finite() || auto.alpha <= 0.0 {
274                return Err("auto-meta-alpha must be a finite value > 0".to_string());
275            }
276            if !auto.beta.is_finite() || auto.beta <= 0.0 {
277                return Err("auto-meta-beta must be a finite value > 0".to_string());
278            }
279            if auto.alpha >= auto.beta {
280                return Err("auto-meta-alpha must be < auto-meta-beta".to_string());
281            }
282            if auto.tick_interval.is_zero() {
283                return Err("auto-meta-tick-interval must be > 0".to_string());
284            }
285            if auto.long_window.is_zero() {
286                return Err("auto-meta-long-window must be > 0".to_string());
287            }
288            if auto.short_window.is_zero() {
289                return Err("auto-meta-short-window must be > 0".to_string());
290            }
291            if auto.short_window >= auto.long_window {
292                return Err("auto-meta-short-window must be < auto-meta-long-window".to_string());
293            }
294            if self.ops_throttle > 0 && self.ops_throttle < AUTO_META_MIN_OPS_THROTTLE {
295                return Err(format!(
296                    "--auto-meta-throttle is incompatible with --ops-throttle={} \
297                     (auto-meta uses a fixed 100ms replenish interval; rates below \
298                     {} ops/sec round to zero tokens per interval and would pause \
299                     the throttle after the initial token). Either raise ops-throttle \
300                     to >= {} or drop --auto-meta-throttle to get the legacy adaptive \
301                     interval.",
302                    self.ops_throttle, AUTO_META_MIN_OPS_THROTTLE, AUTO_META_MIN_OPS_THROTTLE,
303                ));
304            }
305        }
306        let histogram_active = self.histogram_enabled || self.histogram_log_path.is_some();
307        if histogram_active && self.auto_meta.is_none() {
308            return Err(
309                "--auto-meta-histogram and --auto-meta-histogram-log require \
310                 --auto-meta-throttle to be enabled"
311                    .into(),
312            );
313        }
314        if histogram_active {
315            let min = std::time::Duration::from_millis(100);
316            let max = std::time::Duration::from_secs(60);
317            if self.histogram_interval < min || self.histogram_interval > max {
318                return Err(format!(
319                    "--auto-meta-histogram-interval must be in [{}ms, {}s]",
320                    min.as_millis(),
321                    max.as_secs(),
322                ));
323            }
324            if let Some(path) = &self.histogram_log_path {
325                // Path::parent() of "foo.hdr" returns Some("") — empty path,
326                // not None. Treat that the same as None (i.e. current dir).
327                let parent = match path.parent() {
328                    Some(p) if p.as_os_str().is_empty() => std::path::Path::new("."),
329                    Some(p) => p,
330                    None => std::path::Path::new("."),
331                };
332                if !parent.exists() {
333                    return Err(format!(
334                        "--auto-meta-histogram-log parent directory does not exist: {parent:?}",
335                    ));
336                }
337                if !parent.is_dir() {
338                    return Err(format!(
339                        "--auto-meta-histogram-log parent is not a directory: {parent:?}",
340                    ));
341                }
342                // Probe writability: try to create a tiny temp file in the parent.
343                // We don't pre-create the actual log file because the spawn step
344                // adds a trace-identifier suffix.
345                //
346                // Use create_new (O_EXCL) and a random suffix so:
347                //   1. a pre-created symlink with the predictable probe name
348                //      can't redirect the create to an attacker-chosen path, and
349                //   2. two concurrent validations never collide on the probe name.
350                let suffix: u64 = rand::random();
351                let probe = parent.join(format!(
352                    ".rcp-auto-meta-probe-{}-{:016x}",
353                    std::process::id(),
354                    suffix,
355                ));
356                match std::fs::OpenOptions::new()
357                    .create_new(true)
358                    .write(true)
359                    .open(&probe)
360                {
361                    Ok(_) => {
362                        let _ = std::fs::remove_file(&probe);
363                    }
364                    Err(err) => {
365                        return Err(format!(
366                            "--auto-meta-histogram-log parent {parent:?} is not writable: {err:#}",
367                        ));
368                    }
369                }
370            }
371        }
372        Ok(())
373    }
374}
375
376/// Output and logging configuration
377#[derive(Debug, Clone, Copy, Default)]
378pub struct OutputConfig {
379    /// Suppress error output
380    pub quiet: bool,
381    /// Verbosity level: 0=ERROR, 1=INFO, 2=DEBUG, 3=TRACE
382    pub verbose: u8,
383    /// Print summary statistics at the end
384    pub print_summary: bool,
385    /// When true, `run()` will not print text runtime stats after the summary.
386    /// Used when the summary itself includes runtime stats (e.g. JSON format).
387    pub suppress_runtime_stats: bool,
388    /// Prefix pre-tracing configuration failures with a machine-readable startup record.
389    pub startup_error_prefix: Option<&'static str>,
390}
391
392/// Warnings and adjustments for dry-run mode.
393///
394/// When dry-run is active, progress is suppressed (it interferes with stdout
395/// output) and `--summary` is suppressed unless `-v` is also active (verbose
396/// independently enables summary in `common::run()`). This struct collects
397/// warnings about the suppressed flags to print after the operation completes.
398pub struct DryRunWarnings {
399    warnings: Vec<String>,
400}
401impl DryRunWarnings {
402    /// Build dry-run warnings based on which flags were specified.
403    ///
404    /// `has_progress` — whether any progress flags were specified.
405    /// `has_summary` — whether --summary was specified.
406    /// `verbose` — verbosity level; when > 0 summary is printed by `common::run()`
407    ///   regardless of `print_summary`, so we skip the "ignored" warning.
408    /// `has_overwrite` — whether --overwrite was specified (not applicable to rrm).
409    /// `has_filters` — whether --include/--exclude/--filter-file was specified.
410    /// `has_destination` — true for rcp/rlink (copy/link to destination), false for rrm.
411    /// `has_ignore_existing` — whether --ignore-existing was specified (checks destination state).
412    #[must_use]
413    pub fn new(
414        has_progress: bool,
415        has_summary: bool,
416        verbose: u8,
417        has_overwrite: bool,
418        has_filters: bool,
419        has_destination: bool,
420        has_ignore_existing: bool,
421    ) -> Self {
422        let mut warnings = Vec::new();
423        if has_progress {
424            warnings.push("dry-run: --progress was ignored".to_string());
425        }
426        if has_summary && verbose == 0 {
427            warnings.push("dry-run: --summary was ignored".to_string());
428        }
429        if has_overwrite {
430            warnings.push(
431                "dry-run: --overwrite was ignored; dry-run does not check destination state"
432                    .to_string(),
433            );
434        }
435        if !has_filters && !has_ignore_existing {
436            if has_destination {
437                warnings.push(
438                    "dry-run: no filtering specified. dry-run is primarily useful to preview \
439                     --include/--exclude/--filter-file filtering; it does not check whether \
440                     files already exist at the destination."
441                        .to_string(),
442                );
443            } else {
444                warnings.push(
445                    "dry-run: no filtering specified. dry-run is primarily useful to preview \
446                     --include/--exclude/--filter-file filtering."
447                        .to_string(),
448                );
449            }
450        }
451        Self { warnings }
452    }
453    /// Print all collected warnings to stderr.
454    pub fn print(&self) {
455        for warning in &self.warnings {
456            eprintln!("{warning}");
457        }
458    }
459}
460/// Tracing configuration for debugging and profiling
461#[derive(Debug)]
462pub struct TracingConfig {
463    /// Remote tracing layer for distributed tracing
464    pub remote_layer: Option<crate::remote_tracing::RemoteTracingLayer>,
465    /// Debug log file path
466    pub debug_log_file: Option<String>,
467    /// Chrome trace output prefix (produces JSON viewable in Perfetto UI)
468    pub chrome_trace_prefix: Option<String>,
469    /// Flamegraph output prefix (produces folded stacks for inferno)
470    pub flamegraph_prefix: Option<String>,
471    /// Identifier for trace filenames (e.g., "rcp-master", "rcpd-source", "rcpd-destination")
472    pub trace_identifier: String,
473    /// Log level for profiling layers (chrome trace, flamegraph)
474    /// Defaults to "trace" when profiling is enabled
475    pub profile_level: Option<String>,
476    /// Enable tokio-console for live async debugging
477    pub tokio_console: bool,
478    /// Port for tokio-console server (default: 6669)
479    pub tokio_console_port: Option<u16>,
480}
481
482impl TracingConfig {
483    /// Minimal tracing config for the simple local-only tools (`rrm`, `rchm`, `rcmp`, `rlink`,
484    /// `filegen`): no remote layer, no profiling, no tokio-console — only the trace identifier
485    /// varies between them.
486    #[must_use]
487    pub fn local(trace_identifier: &str) -> Self {
488        Self {
489            remote_layer: None,
490            debug_log_file: None,
491            chrome_trace_prefix: None,
492            flamegraph_prefix: None,
493            trace_identifier: trace_identifier.to_string(),
494            profile_level: None,
495            tokio_console: false,
496            tokio_console_port: None,
497        }
498    }
499}
500
501impl Default for TracingConfig {
502    fn default() -> Self {
503        Self {
504            remote_layer: None,
505            debug_log_file: None,
506            chrome_trace_prefix: None,
507            flamegraph_prefix: None,
508            trace_identifier: "unknown".to_string(),
509            profile_level: None,
510            tokio_console: false,
511            tokio_console_port: None,
512        }
513    }
514}
515
516#[cfg(test)]
517mod files_in_flight_policy_tests {
518    use super::*;
519    use std::num::NonZeroUsize;
520
521    #[test]
522    fn limit_meet_uses_the_lower_finite_value() {
523        let four = ConcurrencyLimit::Limited(NonZeroUsize::new(4).unwrap());
524        let eight = ConcurrencyLimit::Limited(NonZeroUsize::new(8).unwrap());
525        assert_eq!(four.meet(eight), four);
526        assert_eq!(ConcurrencyLimit::Unlimited.meet(eight), eight);
527        assert_eq!(eight.meet(ConcurrencyLimit::Unlimited), eight);
528        assert_eq!(
529            ConcurrencyLimit::Unlimited.meet(ConcurrencyLimit::Unlimited),
530            ConcurrencyLimit::Unlimited,
531        );
532    }
533
534    #[test]
535    fn concurrency_limit_display_is_operator_friendly() {
536        let eight = ConcurrencyLimit::Limited(NonZeroUsize::new(8).unwrap());
537        assert_eq!(eight.to_string(), "8");
538        assert_eq!(ConcurrencyLimit::Unlimited.to_string(), "unlimited");
539    }
540
541    #[test]
542    fn explicit_unlimited_file_ceiling_removes_only_the_user_cap() {
543        let limit = ResolvedFilesInFlight::unlimited();
544        assert_eq!(limit.limit(), ConcurrencyLimit::Unlimited);
545        assert_eq!(limit.source(), FilesInFlightSource::Explicit);
546    }
547
548    #[test]
549    fn automatic_files_in_flight_has_a_floor_of_four() {
550        assert_eq!(default_files_in_flight_from(None).get(), 4);
551        assert_eq!(default_files_in_flight_from(NonZeroUsize::new(2)).get(), 4);
552        assert_eq!(default_files_in_flight_from(NonZeroUsize::new(4)).get(), 4);
553        assert_eq!(
554            default_files_in_flight_from(NonZeroUsize::new(12)).get(),
555            12
556        );
557    }
558
559    #[test]
560    fn injected_automatic_file_limit_retains_automatic_provenance() {
561        let limit = ResolvedFilesInFlight::automatic_with(NonZeroUsize::new(12).unwrap());
562        assert_eq!(
563            limit.limit(),
564            ConcurrencyLimit::Limited(NonZeroUsize::new(12).unwrap())
565        );
566        assert_eq!(limit.source(), FilesInFlightSource::Automatic);
567    }
568
569    #[test]
570    fn forwarded_legacy_finite_retains_origin_without_a_duplicate_warning() {
571        let files_in_flight = ResolvedFilesInFlight::forwarded_legacy(7);
572        let throttle = ThrottleConfig {
573            files_in_flight,
574            ..ThrottleConfig::default()
575        };
576        assert_eq!(
577            files_in_flight.limit(),
578            ConcurrencyLimit::Limited(NonZeroUsize::new(7).unwrap())
579        );
580        assert_eq!(
581            files_in_flight.source(),
582            FilesInFlightSource::DeprecatedMaxOpenFiles
583        );
584        assert_eq!(throttle.deprecated_max_open_files_warning(), None);
585    }
586
587    #[test]
588    fn forwarded_legacy_unlimited_retains_origin_without_a_duplicate_warning() {
589        let files_in_flight = ResolvedFilesInFlight::forwarded_legacy(0);
590        let throttle = ThrottleConfig {
591            files_in_flight,
592            ..ThrottleConfig::default()
593        };
594        assert_eq!(files_in_flight.limit(), ConcurrencyLimit::Unlimited);
595        assert_eq!(
596            files_in_flight.source(),
597            FilesInFlightSource::DeprecatedMaxOpenFiles
598        );
599        assert_eq!(throttle.deprecated_max_open_files_warning(), None);
600    }
601}
602
603#[cfg(test)]
604mod auto_meta_validation_tests {
605    use super::*;
606
607    fn valid_auto_meta() -> AutoMetaThrottleConfig {
608        AutoMetaThrottleConfig {
609            initial_cwnd: 1,
610            min_cwnd: 1,
611            max_cwnd: 4096,
612            alpha: 1.3,
613            beta: 1.8,
614            increase_step: 1,
615            decrease_step: 1,
616            baseline_percentile: 0.1,
617            current_percentile: 0.5,
618            long_window: std::time::Duration::from_secs(10),
619            short_window: std::time::Duration::from_secs(1),
620            tick_interval: std::time::Duration::from_millis(50),
621        }
622    }
623
624    fn config_with(auto: AutoMetaThrottleConfig) -> ThrottleConfig {
625        ThrottleConfig {
626            files_in_flight: ResolvedFilesInFlight::automatic(),
627            apply_files_in_flight: true,
628            ops_throttle: 0,
629            iops_throttle: 0,
630            chunk_size: 0,
631            auto_meta: Some(auto),
632            histogram_enabled: false,
633            histogram_log_path: None,
634            histogram_interval: std::time::Duration::from_secs(1),
635        }
636    }
637
638    #[test]
639    fn defaults_validate() {
640        assert!(config_with(valid_auto_meta()).validate().is_ok());
641    }
642
643    #[test]
644    fn min_cwnd_zero_is_rejected() {
645        let mut auto = valid_auto_meta();
646        auto.min_cwnd = 0;
647        let err = config_with(auto).validate().unwrap_err();
648        assert!(err.contains("min-cwnd"), "got: {err}");
649    }
650
651    #[test]
652    fn alpha_at_or_below_zero_is_rejected() {
653        let mut auto = valid_auto_meta();
654        auto.alpha = 0.0;
655        assert!(config_with(auto).validate().is_err());
656        let mut auto = valid_auto_meta();
657        auto.alpha = -0.5;
658        assert!(config_with(auto).validate().is_err());
659    }
660
661    #[test]
662    fn alpha_below_one_is_accepted() {
663        // Passive-controller mode: alpha < 1.0 means "grow only when
664        // recent is meaningfully faster than baseline" — the explicit
665        // use case for relaxing the previous alpha > 1.0 constraint.
666        let mut auto = valid_auto_meta();
667        auto.alpha = 0.9;
668        auto.beta = 1.1;
669        assert!(config_with(auto).validate().is_ok());
670    }
671
672    #[test]
673    fn beta_at_or_below_zero_is_rejected() {
674        let mut auto = valid_auto_meta();
675        auto.alpha = 0.5;
676        auto.beta = 0.0;
677        let err = config_with(auto).validate().unwrap_err();
678        assert!(err.contains("beta"), "got: {err}");
679    }
680
681    #[test]
682    fn cross_percentile_config_validates() {
683        // Cross-percentile mode: baseline at p40, current at p60, with
684        // alpha/beta straddling the steady-state ratio. The validator
685        // accepts both percentiles in (0, 1) with baseline <= current.
686        let mut auto = valid_auto_meta();
687        auto.baseline_percentile = 0.4;
688        auto.current_percentile = 0.6;
689        assert!(config_with(auto).validate().is_ok());
690    }
691
692    #[test]
693    fn baseline_percentile_above_current_is_rejected() {
694        let mut auto = valid_auto_meta();
695        auto.baseline_percentile = 0.6;
696        auto.current_percentile = 0.4;
697        let err = config_with(auto).validate().unwrap_err();
698        assert!(
699            err.contains("baseline-percentile") && err.contains("current-percentile"),
700            "got: {err}",
701        );
702    }
703
704    #[test]
705    fn baseline_percentile_out_of_range_is_rejected() {
706        let mut auto = valid_auto_meta();
707        auto.baseline_percentile = 1.0;
708        let err = config_with(auto).validate().unwrap_err();
709        assert!(err.contains("baseline-percentile"), "got: {err}");
710    }
711
712    #[test]
713    fn non_finite_alpha_or_beta_is_rejected() {
714        // NaN comparisons return false in either direction, so a plain
715        // `auto.alpha <= 0.0` check would silently pass NaN through and
716        // the controller would freeze in the hold band forever. The
717        // `is_finite()` guard catches that.
718        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
719            let mut auto = valid_auto_meta();
720            auto.alpha = bad;
721            assert!(
722                config_with(auto).validate().is_err(),
723                "alpha={bad} must be rejected",
724            );
725            let mut auto = valid_auto_meta();
726            auto.beta = bad;
727            assert!(
728                config_with(auto).validate().is_err(),
729                "beta={bad} must be rejected",
730            );
731        }
732    }
733
734    #[test]
735    fn current_percentile_out_of_range_is_rejected() {
736        let mut auto = valid_auto_meta();
737        auto.current_percentile = 1.0;
738        let err = config_with(auto).validate().unwrap_err();
739        assert!(err.contains("current-percentile"), "got: {err}");
740    }
741
742    #[test]
743    fn ops_throttle_below_floor_is_rejected_under_auto_meta() {
744        let mut config = config_with(valid_auto_meta());
745        config.ops_throttle = 5;
746        let err = config.validate().unwrap_err();
747        assert!(
748            err.contains("ops-throttle") && err.contains("auto-meta-throttle"),
749            "got: {err}",
750        );
751    }
752
753    #[test]
754    fn ops_throttle_at_or_above_floor_is_accepted_under_auto_meta() {
755        let mut config = config_with(valid_auto_meta());
756        config.ops_throttle = AUTO_META_MIN_OPS_THROTTLE;
757        assert!(config.validate().is_ok());
758        config.ops_throttle = AUTO_META_MIN_OPS_THROTTLE + 100;
759        assert!(config.validate().is_ok());
760    }
761
762    #[test]
763    fn ops_throttle_below_floor_is_fine_without_auto_meta() {
764        // The floor only applies when auto-meta forces a fixed 100ms
765        // cadence. Without auto-meta, the adaptive get_replenish_interval
766        // picks an interval that works for any rate.
767        let config = ThrottleConfig {
768            files_in_flight: ResolvedFilesInFlight::automatic(),
769            apply_files_in_flight: true,
770            ops_throttle: 5,
771            iops_throttle: 0,
772            chunk_size: 0,
773            auto_meta: None,
774            histogram_enabled: false,
775            histogram_log_path: None,
776            histogram_interval: std::time::Duration::from_secs(1),
777        };
778        assert!(config.validate().is_ok());
779    }
780
781    #[test]
782    fn alpha_greater_than_beta_is_rejected() {
783        let mut auto = valid_auto_meta();
784        auto.alpha = 1.6;
785        auto.beta = 1.5;
786        let err = config_with(auto).validate().unwrap_err();
787        assert!(err.contains("alpha") && err.contains("beta"), "got: {err}");
788    }
789
790    #[test]
791    fn histogram_log_without_throttle_is_rejected() {
792        // Recording a log requires the throttle pipeline to be live.
793        let config = ThrottleConfig {
794            files_in_flight: ResolvedFilesInFlight::automatic(),
795            apply_files_in_flight: true,
796            ops_throttle: 0,
797            iops_throttle: 0,
798            chunk_size: 0,
799            auto_meta: None,
800            histogram_log_path: Some("/tmp/x.hdr".into()),
801            histogram_enabled: false,
802            histogram_interval: std::time::Duration::from_secs(1),
803        };
804        let err = config.validate().unwrap_err();
805        assert!(
806            err.contains("histogram") && err.contains("auto-meta-throttle"),
807            "got: {err}"
808        );
809    }
810
811    #[test]
812    fn histogram_enabled_without_throttle_is_rejected() {
813        // --auto-meta-histogram alone (no log path) without --auto-meta-throttle
814        // is rejected for the same reason: nothing to histogram.
815        let config = ThrottleConfig {
816            files_in_flight: ResolvedFilesInFlight::automatic(),
817            apply_files_in_flight: true,
818            ops_throttle: 0,
819            iops_throttle: 0,
820            chunk_size: 0,
821            auto_meta: None,
822            histogram_enabled: true,
823            histogram_log_path: None,
824            histogram_interval: std::time::Duration::from_secs(1),
825        };
826        let err = config.validate().unwrap_err();
827        assert!(
828            err.contains("histogram") && err.contains("auto-meta-throttle"),
829            "got: {err}"
830        );
831    }
832
833    #[test]
834    fn histogram_interval_below_floor_is_rejected() {
835        let mut config = config_with(valid_auto_meta());
836        config.histogram_enabled = true;
837        config.histogram_interval = std::time::Duration::from_millis(50);
838        let err = config.validate().unwrap_err();
839        assert!(err.contains("histogram-interval"), "got: {err}");
840    }
841
842    #[test]
843    fn histogram_interval_above_ceiling_is_rejected() {
844        let mut config = config_with(valid_auto_meta());
845        config.histogram_enabled = true;
846        config.histogram_interval = std::time::Duration::from_secs(120);
847        let err = config.validate().unwrap_err();
848        assert!(err.contains("histogram-interval"), "got: {err}");
849    }
850
851    #[test]
852    fn histogram_defaults_pass_validation() {
853        let mut config = config_with(valid_auto_meta());
854        config.histogram_enabled = true;
855        config.histogram_interval = std::time::Duration::from_secs(1);
856        assert!(config.validate().is_ok());
857    }
858
859    #[test]
860    fn histogram_log_with_missing_parent_is_rejected() {
861        let mut config = config_with(valid_auto_meta());
862        config.histogram_log_path = Some("/nonexistent-dir-12345/foo.hdr".into());
863        let err = config.validate().unwrap_err();
864        assert!(
865            err.contains("histogram-log") && err.contains("parent"),
866            "got: {err}",
867        );
868    }
869
870    #[test]
871    fn histogram_log_with_writable_parent_is_accepted() {
872        let dir = tempfile::tempdir().unwrap();
873        let mut config = config_with(valid_auto_meta());
874        config.histogram_log_path = Some(dir.path().join("foo.hdr"));
875        assert!(config.validate().is_ok());
876    }
877
878    #[test]
879    fn histogram_log_with_bare_filename_is_accepted() {
880        // Path::parent() of "foo.hdr" returns Some("") — empty path, not
881        // None. The validator must treat that as the current directory,
882        // not as a missing parent.
883        let mut config = config_with(valid_auto_meta());
884        config.histogram_log_path = Some("bare-filename.hdr".into());
885        assert!(
886            config.validate().is_ok(),
887            "validate err: {:?}",
888            config.validate(),
889        );
890    }
891
892    #[test]
893    fn histogram_log_validation_uses_unique_probe_per_call() {
894        // Two consecutive validations with the same path must succeed —
895        // proving the probe file is removed cleanly and the filename
896        // doesn't collide with itself.
897        let dir = tempfile::tempdir().unwrap();
898        let mut config = config_with(valid_auto_meta());
899        config.histogram_log_path = Some(dir.path().join("log.hdr"));
900        assert!(config.validate().is_ok());
901        assert!(config.validate().is_ok());
902    }
903}