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 TracingConfig {
355    /// Minimal tracing config for the simple local-only tools (`rrm`, `rchm`, `rcmp`, `rlink`,
356    /// `filegen`): no remote layer, no profiling, no tokio-console — only the trace identifier
357    /// varies between them.
358    #[must_use]
359    pub fn local(trace_identifier: &str) -> Self {
360        Self {
361            remote_layer: None,
362            debug_log_file: None,
363            chrome_trace_prefix: None,
364            flamegraph_prefix: None,
365            trace_identifier: trace_identifier.to_string(),
366            profile_level: None,
367            tokio_console: false,
368            tokio_console_port: None,
369        }
370    }
371}
372
373impl Default for TracingConfig {
374    fn default() -> Self {
375        Self {
376            remote_layer: None,
377            debug_log_file: None,
378            chrome_trace_prefix: None,
379            flamegraph_prefix: None,
380            trace_identifier: "unknown".to_string(),
381            profile_level: None,
382            tokio_console: false,
383            tokio_console_port: None,
384        }
385    }
386}
387
388#[cfg(test)]
389mod auto_meta_validation_tests {
390    use super::*;
391
392    fn valid_auto_meta() -> AutoMetaThrottleConfig {
393        AutoMetaThrottleConfig {
394            initial_cwnd: 1,
395            min_cwnd: 1,
396            max_cwnd: 4096,
397            alpha: 1.3,
398            beta: 1.8,
399            increase_step: 1,
400            decrease_step: 1,
401            baseline_percentile: 0.1,
402            current_percentile: 0.5,
403            long_window: std::time::Duration::from_secs(10),
404            short_window: std::time::Duration::from_secs(1),
405            tick_interval: std::time::Duration::from_millis(50),
406        }
407    }
408
409    fn config_with(auto: AutoMetaThrottleConfig) -> ThrottleConfig {
410        ThrottleConfig {
411            max_open_files: None,
412            ops_throttle: 0,
413            iops_throttle: 0,
414            chunk_size: 0,
415            auto_meta: Some(auto),
416            histogram_enabled: false,
417            histogram_log_path: None,
418            histogram_interval: std::time::Duration::from_secs(1),
419        }
420    }
421
422    #[test]
423    fn defaults_validate() {
424        assert!(config_with(valid_auto_meta()).validate().is_ok());
425    }
426
427    #[test]
428    fn min_cwnd_zero_is_rejected() {
429        let mut auto = valid_auto_meta();
430        auto.min_cwnd = 0;
431        let err = config_with(auto).validate().unwrap_err();
432        assert!(err.contains("min-cwnd"), "got: {err}");
433    }
434
435    #[test]
436    fn alpha_at_or_below_zero_is_rejected() {
437        let mut auto = valid_auto_meta();
438        auto.alpha = 0.0;
439        assert!(config_with(auto).validate().is_err());
440        let mut auto = valid_auto_meta();
441        auto.alpha = -0.5;
442        assert!(config_with(auto).validate().is_err());
443    }
444
445    #[test]
446    fn alpha_below_one_is_accepted() {
447        // Passive-controller mode: alpha < 1.0 means "grow only when
448        // recent is meaningfully faster than baseline" — the explicit
449        // use case for relaxing the previous alpha > 1.0 constraint.
450        let mut auto = valid_auto_meta();
451        auto.alpha = 0.9;
452        auto.beta = 1.1;
453        assert!(config_with(auto).validate().is_ok());
454    }
455
456    #[test]
457    fn beta_at_or_below_zero_is_rejected() {
458        let mut auto = valid_auto_meta();
459        auto.alpha = 0.5;
460        auto.beta = 0.0;
461        let err = config_with(auto).validate().unwrap_err();
462        assert!(err.contains("beta"), "got: {err}");
463    }
464
465    #[test]
466    fn cross_percentile_config_validates() {
467        // Cross-percentile mode: baseline at p40, current at p60, with
468        // alpha/beta straddling the steady-state ratio. The validator
469        // accepts both percentiles in (0, 1) with baseline <= current.
470        let mut auto = valid_auto_meta();
471        auto.baseline_percentile = 0.4;
472        auto.current_percentile = 0.6;
473        assert!(config_with(auto).validate().is_ok());
474    }
475
476    #[test]
477    fn baseline_percentile_above_current_is_rejected() {
478        let mut auto = valid_auto_meta();
479        auto.baseline_percentile = 0.6;
480        auto.current_percentile = 0.4;
481        let err = config_with(auto).validate().unwrap_err();
482        assert!(
483            err.contains("baseline-percentile") && err.contains("current-percentile"),
484            "got: {err}",
485        );
486    }
487
488    #[test]
489    fn baseline_percentile_out_of_range_is_rejected() {
490        let mut auto = valid_auto_meta();
491        auto.baseline_percentile = 1.0;
492        let err = config_with(auto).validate().unwrap_err();
493        assert!(err.contains("baseline-percentile"), "got: {err}");
494    }
495
496    #[test]
497    fn non_finite_alpha_or_beta_is_rejected() {
498        // NaN comparisons return false in either direction, so a plain
499        // `auto.alpha <= 0.0` check would silently pass NaN through and
500        // the controller would freeze in the hold band forever. The
501        // `is_finite()` guard catches that.
502        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
503            let mut auto = valid_auto_meta();
504            auto.alpha = bad;
505            assert!(
506                config_with(auto).validate().is_err(),
507                "alpha={bad} must be rejected",
508            );
509            let mut auto = valid_auto_meta();
510            auto.beta = bad;
511            assert!(
512                config_with(auto).validate().is_err(),
513                "beta={bad} must be rejected",
514            );
515        }
516    }
517
518    #[test]
519    fn current_percentile_out_of_range_is_rejected() {
520        let mut auto = valid_auto_meta();
521        auto.current_percentile = 1.0;
522        let err = config_with(auto).validate().unwrap_err();
523        assert!(err.contains("current-percentile"), "got: {err}");
524    }
525
526    #[test]
527    fn ops_throttle_below_floor_is_rejected_under_auto_meta() {
528        let mut config = config_with(valid_auto_meta());
529        config.ops_throttle = 5;
530        let err = config.validate().unwrap_err();
531        assert!(
532            err.contains("ops-throttle") && err.contains("auto-meta-throttle"),
533            "got: {err}",
534        );
535    }
536
537    #[test]
538    fn ops_throttle_at_or_above_floor_is_accepted_under_auto_meta() {
539        let mut config = config_with(valid_auto_meta());
540        config.ops_throttle = AUTO_META_MIN_OPS_THROTTLE;
541        assert!(config.validate().is_ok());
542        config.ops_throttle = AUTO_META_MIN_OPS_THROTTLE + 100;
543        assert!(config.validate().is_ok());
544    }
545
546    #[test]
547    fn ops_throttle_below_floor_is_fine_without_auto_meta() {
548        // The floor only applies when auto-meta forces a fixed 100ms
549        // cadence. Without auto-meta, the adaptive get_replenish_interval
550        // picks an interval that works for any rate.
551        let config = ThrottleConfig {
552            max_open_files: None,
553            ops_throttle: 5,
554            iops_throttle: 0,
555            chunk_size: 0,
556            auto_meta: None,
557            histogram_enabled: false,
558            histogram_log_path: None,
559            histogram_interval: std::time::Duration::from_secs(1),
560        };
561        assert!(config.validate().is_ok());
562    }
563
564    #[test]
565    fn alpha_greater_than_beta_is_rejected() {
566        let mut auto = valid_auto_meta();
567        auto.alpha = 1.6;
568        auto.beta = 1.5;
569        let err = config_with(auto).validate().unwrap_err();
570        assert!(err.contains("alpha") && err.contains("beta"), "got: {err}");
571    }
572
573    #[test]
574    fn histogram_log_without_throttle_is_rejected() {
575        // Recording a log requires the throttle pipeline to be live.
576        let config = ThrottleConfig {
577            max_open_files: None,
578            ops_throttle: 0,
579            iops_throttle: 0,
580            chunk_size: 0,
581            auto_meta: None,
582            histogram_log_path: Some("/tmp/x.hdr".into()),
583            histogram_enabled: false,
584            histogram_interval: std::time::Duration::from_secs(1),
585        };
586        let err = config.validate().unwrap_err();
587        assert!(
588            err.contains("histogram") && err.contains("auto-meta-throttle"),
589            "got: {err}"
590        );
591    }
592
593    #[test]
594    fn histogram_enabled_without_throttle_is_rejected() {
595        // --auto-meta-histogram alone (no log path) without --auto-meta-throttle
596        // is rejected for the same reason: nothing to histogram.
597        let config = ThrottleConfig {
598            max_open_files: None,
599            ops_throttle: 0,
600            iops_throttle: 0,
601            chunk_size: 0,
602            auto_meta: None,
603            histogram_enabled: true,
604            histogram_log_path: None,
605            histogram_interval: std::time::Duration::from_secs(1),
606        };
607        let err = config.validate().unwrap_err();
608        assert!(
609            err.contains("histogram") && err.contains("auto-meta-throttle"),
610            "got: {err}"
611        );
612    }
613
614    #[test]
615    fn histogram_interval_below_floor_is_rejected() {
616        let mut config = config_with(valid_auto_meta());
617        config.histogram_enabled = true;
618        config.histogram_interval = std::time::Duration::from_millis(50);
619        let err = config.validate().unwrap_err();
620        assert!(err.contains("histogram-interval"), "got: {err}");
621    }
622
623    #[test]
624    fn histogram_interval_above_ceiling_is_rejected() {
625        let mut config = config_with(valid_auto_meta());
626        config.histogram_enabled = true;
627        config.histogram_interval = std::time::Duration::from_secs(120);
628        let err = config.validate().unwrap_err();
629        assert!(err.contains("histogram-interval"), "got: {err}");
630    }
631
632    #[test]
633    fn histogram_defaults_pass_validation() {
634        let mut config = config_with(valid_auto_meta());
635        config.histogram_enabled = true;
636        config.histogram_interval = std::time::Duration::from_secs(1);
637        assert!(config.validate().is_ok());
638    }
639
640    #[test]
641    fn histogram_log_with_missing_parent_is_rejected() {
642        let mut config = config_with(valid_auto_meta());
643        config.histogram_log_path = Some("/nonexistent-dir-12345/foo.hdr".into());
644        let err = config.validate().unwrap_err();
645        assert!(
646            err.contains("histogram-log") && err.contains("parent"),
647            "got: {err}",
648        );
649    }
650
651    #[test]
652    fn histogram_log_with_writable_parent_is_accepted() {
653        let dir = tempfile::tempdir().unwrap();
654        let mut config = config_with(valid_auto_meta());
655        config.histogram_log_path = Some(dir.path().join("foo.hdr"));
656        assert!(config.validate().is_ok());
657    }
658
659    #[test]
660    fn histogram_log_with_bare_filename_is_accepted() {
661        // Path::parent() of "foo.hdr" returns Some("") — empty path, not
662        // None. The validator must treat that as the current directory,
663        // not as a missing parent.
664        let mut config = config_with(valid_auto_meta());
665        config.histogram_log_path = Some("bare-filename.hdr".into());
666        assert!(
667            config.validate().is_ok(),
668            "validate err: {:?}",
669            config.validate(),
670        );
671    }
672
673    #[test]
674    fn histogram_log_validation_uses_unique_probe_per_call() {
675        // Two consecutive validations with the same path must succeed —
676        // proving the probe file is removed cleanly and the filename
677        // doesn't collide with itself.
678        let dir = tempfile::tempdir().unwrap();
679        let mut config = config_with(valid_auto_meta());
680        config.histogram_log_path = Some(dir.path().join("log.hdr"));
681        assert!(config.validate().is_ok());
682        assert!(config.validate().is_ok());
683    }
684}