Skip to main content

common/
config.rs

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