1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
116pub enum DryRunMode {
117 #[value(name = "brief")]
119 Brief,
120 #[value(name = "all")]
122 All,
123 #[value(name = "explain")]
125 Explain,
126}
127
128#[derive(Debug, Clone, Copy, Default)]
130pub struct RuntimeConfig {
131 pub max_workers: usize,
133 pub max_blocking_threads: usize,
135}
136
137#[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 pub baseline_percentile: f64,
155 pub current_percentile: f64,
158 pub long_window: std::time::Duration,
160 pub short_window: std::time::Duration,
162 pub tick_interval: std::time::Duration,
163}
164
165#[derive(Debug, Clone)]
167pub struct ThrottleConfig {
168 pub files_in_flight: ResolvedFilesInFlight,
170 pub apply_files_in_flight: bool,
172 pub ops_throttle: usize,
174 pub iops_throttle: usize,
176 pub chunk_size: u64,
178 pub auto_meta: Option<AutoMetaThrottleConfig>,
180 pub histogram_enabled: bool,
184 pub histogram_log_path: Option<std::path::PathBuf>,
188 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
209pub 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 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 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 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 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#[derive(Debug, Clone, Copy, Default)]
378pub struct OutputConfig {
379 pub quiet: bool,
381 pub verbose: u8,
383 pub print_summary: bool,
385 pub suppress_runtime_stats: bool,
388 pub startup_error_prefix: Option<&'static str>,
390}
391
392pub struct DryRunWarnings {
399 warnings: Vec<String>,
400}
401impl DryRunWarnings {
402 #[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 pub fn print(&self) {
455 for warning in &self.warnings {
456 eprintln!("{warning}");
457 }
458 }
459}
460#[derive(Debug)]
462pub struct TracingConfig {
463 pub remote_layer: Option<crate::remote_tracing::RemoteTracingLayer>,
465 pub debug_log_file: Option<String>,
467 pub chrome_trace_prefix: Option<String>,
469 pub flamegraph_prefix: Option<String>,
471 pub trace_identifier: String,
473 pub profile_level: Option<String>,
476 pub tokio_console: bool,
478 pub tokio_console_port: Option<u16>,
480}
481
482impl TracingConfig {
483 #[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 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 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 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 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 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 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 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 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}