1use serde::{Deserialize, Serialize};
56use std::os::unix::fs::MetadataExt;
57use std::os::unix::prelude::PermissionsExt;
58
59pub const DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES: usize = 5_000_000;
64
65#[derive(Clone, Debug, Deserialize, Serialize)]
66pub struct Metadata {
67 pub mode: u32,
68 pub uid: u32,
69 pub gid: u32,
70 pub atime: i64,
71 pub mtime: i64,
72 pub atime_nsec: i64,
73 pub mtime_nsec: i64,
74}
75
76impl common::preserve::Metadata for Metadata {
77 fn uid(&self) -> u32 {
78 self.uid
79 }
80 fn gid(&self) -> u32 {
81 self.gid
82 }
83 fn atime(&self) -> i64 {
84 self.atime
85 }
86 fn atime_nsec(&self) -> i64 {
87 self.atime_nsec
88 }
89 fn mtime(&self) -> i64 {
90 self.mtime
91 }
92 fn mtime_nsec(&self) -> i64 {
93 self.mtime_nsec
94 }
95 fn permissions(&self) -> std::fs::Permissions {
96 std::fs::Permissions::from_mode(self.mode)
97 }
98}
99
100impl common::preserve::Metadata for &Metadata {
101 fn uid(&self) -> u32 {
102 (*self).uid()
103 }
104 fn gid(&self) -> u32 {
105 (*self).gid()
106 }
107 fn atime(&self) -> i64 {
108 (*self).atime()
109 }
110 fn atime_nsec(&self) -> i64 {
111 (*self).atime_nsec()
112 }
113 fn mtime(&self) -> i64 {
114 (*self).mtime()
115 }
116 fn mtime_nsec(&self) -> i64 {
117 (*self).mtime_nsec()
118 }
119 fn permissions(&self) -> std::fs::Permissions {
120 (*self).permissions()
121 }
122}
123
124impl From<&std::fs::Metadata> for Metadata {
125 fn from(metadata: &std::fs::Metadata) -> Self {
126 Metadata {
127 mode: metadata.mode(),
128 uid: metadata.uid(),
129 gid: metadata.gid(),
130 atime: metadata.atime(),
131 mtime: metadata.mtime(),
132 atime_nsec: metadata.atime_nsec(),
133 mtime_nsec: metadata.mtime_nsec(),
134 }
135 }
136}
137
138impl From<&common::safedir::FileMeta> for Metadata {
139 fn from(meta: &common::safedir::FileMeta) -> Self {
144 use common::preserve::Metadata as _;
145 Metadata {
146 mode: meta.permissions().mode(),
147 uid: meta.uid(),
148 gid: meta.gid(),
149 atime: meta.atime(),
150 mtime: meta.mtime(),
151 atime_nsec: meta.atime_nsec(),
152 mtime_nsec: meta.mtime_nsec(),
153 }
154 }
155}
156
157#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct ExistingEntry {
163 pub name: std::path::PathBuf,
164 pub is_file: bool,
165 pub metadata: Metadata,
166 pub size: u64,
167}
168
169pub const MANIFEST_CHUNK_BYTE_BUDGET: usize = 4 * 1024 * 1024;
173
174fn estimate_entry_size(entry: &ExistingEntry) -> usize {
178 entry.name.as_os_str().len() + 64
179}
180
181pub fn chunk_manifest(entries: Vec<ExistingEntry>, byte_budget: usize) -> Vec<Vec<ExistingEntry>> {
187 let mut chunks: Vec<Vec<ExistingEntry>> = Vec::new();
188 let mut current: Vec<ExistingEntry> = Vec::new();
189 let mut current_bytes = 0usize;
190 for entry in entries {
191 let size = estimate_entry_size(&entry);
192 if !current.is_empty() && current_bytes + size > byte_budget {
193 chunks.push(std::mem::take(&mut current));
194 current_bytes = 0;
195 }
196 current_bytes += size;
197 current.push(entry);
198 }
199 if !current.is_empty() {
200 chunks.push(current);
201 }
202 chunks
203}
204
205#[derive(Debug, Deserialize, Serialize)]
207pub struct File {
208 pub src: std::path::PathBuf,
209 pub dst: std::path::PathBuf,
210 pub size: u64,
211 pub metadata: Metadata,
212 pub is_root: bool,
213}
214
215#[derive(Debug)]
217pub struct FileMetadata<'a> {
218 pub metadata: &'a Metadata,
219 pub size: u64,
220}
221
222impl<'a> common::preserve::Metadata for FileMetadata<'a> {
223 fn uid(&self) -> u32 {
224 self.metadata.uid()
225 }
226 fn gid(&self) -> u32 {
227 self.metadata.gid()
228 }
229 fn atime(&self) -> i64 {
230 self.metadata.atime()
231 }
232 fn atime_nsec(&self) -> i64 {
233 self.metadata.atime_nsec()
234 }
235 fn mtime(&self) -> i64 {
236 self.metadata.mtime()
237 }
238 fn mtime_nsec(&self) -> i64 {
239 self.metadata.mtime_nsec()
240 }
241 fn permissions(&self) -> std::fs::Permissions {
242 self.metadata.permissions()
243 }
244 fn size(&self) -> u64 {
245 self.size
246 }
247}
248
249#[derive(Debug, Deserialize, Serialize)]
251pub enum SourceMessage {
252 Directory {
256 src: std::path::PathBuf,
257 dst: std::path::PathBuf,
258 metadata: Metadata,
259 is_root: bool,
260 entry_count: usize,
262 keep_if_empty: bool,
264 },
265 Symlink {
267 src: std::path::PathBuf,
268 dst: std::path::PathBuf,
269 target: std::path::PathBuf,
270 metadata: Metadata,
271 is_root: bool,
272 },
273 DirStructureComplete { has_root_item: bool },
278 FileSkipped {
281 src: std::path::PathBuf,
282 dst: std::path::PathBuf,
283 },
284 FileUnchanged {
289 src: std::path::PathBuf,
290 dst: std::path::PathBuf,
291 },
292 SymlinkSkipped { src_dst: SrcDst, is_root: bool },
296}
297
298#[derive(Clone, Debug, Deserialize, Serialize)]
299pub struct SrcDst {
300 pub src: std::path::PathBuf,
301 pub dst: std::path::PathBuf,
302}
303
304#[derive(Clone, Debug, Deserialize, Serialize)]
306pub enum DestinationMessage {
307 DirectoryManifestChunk {
315 dst: std::path::PathBuf,
316 entries: Vec<ExistingEntry>,
317 },
318 DirectoryCreated {
325 src: std::path::PathBuf,
326 dst: std::path::PathBuf,
327 },
328 DirectorySkipped {
339 src: std::path::PathBuf,
340 dst: std::path::PathBuf,
341 },
342 DestinationDone,
345}
346
347#[derive(Clone, Debug, Deserialize, Serialize)]
348pub struct RcpdConfig {
349 pub verbose: u8,
350 pub fail_early: bool,
351 pub max_workers: usize,
352 pub max_blocking_threads: usize,
353 pub max_open_files: Option<usize>,
354 pub ops_throttle: usize,
355 pub iops_throttle: usize,
356 pub chunk_size: usize,
357 pub auto_meta: Option<common::AutoMetaThrottleConfig>,
361 pub auto_meta_histogram: bool,
363 pub auto_meta_histogram_log: Option<String>,
367 pub auto_meta_histogram_interval: std::time::Duration,
369 pub dereference: bool,
371 pub overwrite: bool,
372 pub overwrite_compare: String,
373 pub overwrite_manifest_max_entries: usize,
375 pub overwrite_filter: Option<String>,
376 pub ignore_existing: bool,
377 pub skip_specials: bool,
378 pub debug_log_prefix: Option<String>,
379 pub port_ranges: Option<String>,
381 pub progress: bool,
382 pub progress_delay: Option<String>,
383 pub remote_copy_conn_timeout_sec: u64,
384 pub network_profile: crate::NetworkProfile,
386 pub buffer_size: Option<usize>,
388 pub max_connections: usize,
390 pub pending_writes_multiplier: usize,
392 pub chrome_trace_prefix: Option<String>,
394 pub flamegraph_prefix: Option<String>,
396 pub profile_level: Option<String>,
398 pub tokio_console: bool,
400 pub tokio_console_port: Option<u16>,
402 pub encryption: bool,
404 pub master_cert_fingerprint: Option<CertFingerprint>,
406}
407
408impl RcpdConfig {
409 pub fn to_args(&self) -> Vec<String> {
410 let mut args = vec![
411 format!("--max-workers={}", self.max_workers),
412 format!("--max-blocking-threads={}", self.max_blocking_threads),
413 format!("--ops-throttle={}", self.ops_throttle),
414 format!("--iops-throttle={}", self.iops_throttle),
415 format!("--chunk-size={}", self.chunk_size),
416 format!("--overwrite-compare={}", self.overwrite_compare),
417 format!(
418 "--overwrite-manifest-max-entries={}",
419 self.overwrite_manifest_max_entries
420 ),
421 ];
422 if self.verbose > 0 {
423 args.push(format!("-{}", "v".repeat(self.verbose as usize)));
424 }
425 if self.fail_early {
426 args.push("--fail-early".to_string());
427 }
428 if let Some(v) = self.max_open_files {
429 args.push(format!("--max-open-files={v}"));
430 }
431 if self.dereference {
432 args.push("--dereference".to_string());
433 }
434 if self.overwrite {
435 args.push("--overwrite".to_string());
436 if let Some(ref filter) = self.overwrite_filter {
437 args.push(format!("--overwrite-filter={filter}"));
438 }
439 }
440 if self.ignore_existing {
441 args.push("--ignore-existing".to_string());
442 }
443 if self.skip_specials {
444 args.push("--skip-specials".to_string());
445 }
446 if let Some(ref prefix) = self.debug_log_prefix {
447 args.push(format!("--debug-log-prefix={prefix}"));
448 }
449 if let Some(ref ranges) = self.port_ranges {
450 args.push(format!("--port-ranges={ranges}"));
451 }
452 if self.progress {
453 args.push("--progress".to_string());
454 }
455 if let Some(ref delay) = self.progress_delay {
456 args.push(format!("--progress-delay={delay}"));
457 }
458 args.push(format!(
459 "--remote-copy-conn-timeout-sec={}",
460 self.remote_copy_conn_timeout_sec
461 ));
462 args.push(format!("--network-profile={}", self.network_profile));
464 if let Some(v) = self.buffer_size {
466 args.push(format!("--buffer-size={v}"));
467 }
468 args.push(format!("--max-connections={}", self.max_connections));
469 args.push(format!(
470 "--pending-writes-multiplier={}",
471 self.pending_writes_multiplier
472 ));
473 let profiling_enabled =
475 self.chrome_trace_prefix.is_some() || self.flamegraph_prefix.is_some();
476 if let Some(ref prefix) = self.chrome_trace_prefix {
477 args.push(format!("--chrome-trace={prefix}"));
478 }
479 if let Some(ref prefix) = self.flamegraph_prefix {
480 args.push(format!("--flamegraph={prefix}"));
481 }
482 if profiling_enabled && let Some(level) = &self.profile_level {
483 args.push(format!("--profile-level={level}"));
484 }
485 if self.tokio_console {
486 args.push("--tokio-console".to_string());
487 }
488 if let Some(port) = self.tokio_console_port {
489 args.push(format!("--tokio-console-port={port}"));
490 }
491 if !self.encryption {
492 args.push("--no-encryption".to_string());
493 }
494 if let Some(fp) = self.master_cert_fingerprint {
495 args.push(format!(
496 "--master-cert-fp={}",
497 crate::tls::fingerprint_to_hex(&fp)
498 ));
499 }
500 if let Some(auto) = &self.auto_meta {
503 args.push("--auto-meta-throttle".to_string());
504 args.push(format!("--auto-meta-initial-cwnd={}", auto.initial_cwnd));
505 args.push(format!("--auto-meta-min-cwnd={}", auto.min_cwnd));
506 args.push(format!("--auto-meta-max-cwnd={}", auto.max_cwnd));
507 args.push(format!("--auto-meta-alpha={}", auto.alpha));
508 args.push(format!("--auto-meta-beta={}", auto.beta));
509 args.push(format!(
510 "--auto-meta-baseline-percentile={}",
511 auto.baseline_percentile,
512 ));
513 args.push(format!(
514 "--auto-meta-current-percentile={}",
515 auto.current_percentile,
516 ));
517 args.push(format!("--auto-meta-increase-step={}", auto.increase_step));
518 args.push(format!("--auto-meta-decrease-step={}", auto.decrease_step));
519 args.push(format!(
520 "--auto-meta-long-window={}",
521 humantime::format_duration(auto.long_window),
522 ));
523 args.push(format!(
524 "--auto-meta-short-window={}",
525 humantime::format_duration(auto.short_window),
526 ));
527 args.push(format!(
528 "--auto-meta-tick-interval={}",
529 humantime::format_duration(auto.tick_interval),
530 ));
531 }
532 if let Some(path) = &self.auto_meta_histogram_log {
540 args.push(format!("--auto-meta-histogram-log={path}"));
541 args.push(format!(
542 "--auto-meta-histogram-interval={}",
543 humantime::format_duration(self.auto_meta_histogram_interval),
544 ));
545 }
546 args
547 }
548}
549
550#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
551pub enum RcpdRole {
552 Source,
553 Destination,
554}
555
556impl std::fmt::Display for RcpdRole {
557 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558 match self {
559 RcpdRole::Source => write!(f, "source"),
560 RcpdRole::Destination => write!(f, "destination"),
561 }
562 }
563}
564
565impl std::str::FromStr for RcpdRole {
566 type Err = anyhow::Error;
567 fn from_str(s: &str) -> Result<Self, Self::Err> {
568 match s.to_lowercase().as_str() {
569 "source" => Ok(RcpdRole::Source),
570 "destination" | "dest" => Ok(RcpdRole::Destination),
571 _ => Err(anyhow::anyhow!("invalid role: {}", s)),
572 }
573 }
574}
575
576#[derive(Clone, Debug, Deserialize, Serialize)]
577pub struct TracingHello {
578 pub role: RcpdRole,
579 pub is_tracing: bool,
581}
582
583pub type CertFingerprint = [u8; 32];
585
586#[derive(Clone, Debug, Deserialize, Serialize)]
587pub enum MasterHello {
588 Source {
589 src: std::path::PathBuf,
590 dst: std::path::PathBuf,
591 dest_cert_fingerprint: Option<CertFingerprint>,
593 filter: Option<common::filter::FilterSettings>,
595 dry_run: Option<common::config::DryRunMode>,
597 },
598 Destination {
599 source_control_addr: std::net::SocketAddr,
601 source_data_addr: std::net::SocketAddr,
603 server_name: String,
604 preserve: common::preserve::Settings,
605 source_cert_fingerprint: Option<CertFingerprint>,
607 },
608}
609
610#[derive(Clone, Debug, Deserialize, Serialize)]
611pub struct SourceMasterHello {
612 pub control_addr: std::net::SocketAddr,
614 pub data_addr: std::net::SocketAddr,
616 pub server_name: String,
617}
618
619pub use common::RuntimeStats;
621
622#[derive(Clone, Debug, Deserialize, Serialize)]
623pub enum RcpdResult {
624 Success {
625 message: String,
626 summary: common::copy::Summary,
627 runtime_stats: common::RuntimeStats,
628 },
629 Failure {
630 error: String,
631 summary: common::copy::Summary,
632 runtime_stats: common::RuntimeStats,
633 },
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 fn mk_entry(name: &str) -> ExistingEntry {
641 ExistingEntry {
642 name: std::path::PathBuf::from(name),
643 is_file: true,
644 metadata: Metadata {
645 mode: 0o644,
646 uid: 0,
647 gid: 0,
648 atime: 0,
649 mtime: 0,
650 atime_nsec: 0,
651 mtime_nsec: 0,
652 },
653 size: 0,
654 }
655 }
656
657 #[test]
658 fn chunk_manifest_empty_yields_no_chunks() {
659 assert!(chunk_manifest(vec![], MANIFEST_CHUNK_BYTE_BUDGET).is_empty());
660 }
661
662 #[test]
663 fn chunk_manifest_small_is_single_chunk() {
664 let entries: Vec<_> = (0..100).map(|i| mk_entry(&format!("f{i}.txt"))).collect();
665 let chunks = chunk_manifest(entries, MANIFEST_CHUNK_BYTE_BUDGET);
666 assert_eq!(chunks.len(), 1);
667 assert_eq!(chunks[0].len(), 100);
668 }
669
670 #[test]
671 fn chunk_manifest_splits_and_preserves_all_entries_in_order() {
672 let entries: Vec<_> = (0..1000)
673 .map(|i| mk_entry(&format!("file_{i:04}.dat")))
674 .collect();
675 let chunks = chunk_manifest(entries.clone(), 256);
677 assert!(
678 chunks.len() > 1,
679 "tiny budget should produce multiple chunks"
680 );
681 for chunk in &chunks {
684 if chunk.len() > 1 {
685 let total: usize = chunk.iter().map(estimate_entry_size).sum();
686 assert!(total <= 256, "multi-entry chunk exceeds budget: {total}");
687 }
688 }
689 let flat: Vec<_> = chunks.into_iter().flatten().collect();
691 assert_eq!(flat.len(), entries.len());
692 for (got, want) in flat.iter().zip(entries.iter()) {
693 assert_eq!(got.name, want.name);
694 }
695 }
696
697 #[test]
698 fn chunk_manifest_entry_larger_than_budget_gets_its_own_chunk() {
699 let chunks = chunk_manifest(vec![mk_entry("a"), mk_entry("b")], 1);
701 assert_eq!(chunks.len(), 2);
702 assert_eq!(chunks[0].len(), 1);
703 assert_eq!(chunks[1].len(), 1);
704 }
705
706 #[test]
707 fn chunk_manifest_splits_a_large_manifest_at_the_production_budget() {
708 let per_entry = estimate_entry_size(&mk_entry("file_0000000.dat"));
713 let n = (MANIFEST_CHUNK_BYTE_BUDGET / per_entry) * 2 + 1000;
714 let entries: Vec<_> = (0..n)
715 .map(|i| mk_entry(&format!("file_{i:07}.dat")))
716 .collect();
717 let chunks = chunk_manifest(entries.clone(), MANIFEST_CHUNK_BYTE_BUDGET);
718 assert!(
719 chunks.len() > 1,
720 "a manifest larger than the 4 MiB budget must span multiple chunks, got {}",
721 chunks.len()
722 );
723 for chunk in &chunks {
725 if chunk.len() > 1 {
726 let total: usize = chunk.iter().map(estimate_entry_size).sum();
727 assert!(
728 total <= MANIFEST_CHUNK_BYTE_BUDGET,
729 "a chunk exceeds the production budget: {total}"
730 );
731 }
732 }
733 let flat: Vec<_> = chunks.into_iter().flatten().collect();
735 assert_eq!(
736 flat.len(),
737 entries.len(),
738 "reassembly must preserve every entry"
739 );
740 assert!(
741 flat.iter().zip(&entries).all(|(a, b)| a.name == b.name),
742 "reassembly must preserve order"
743 );
744 }
745
746 fn minimal_rcpd_config() -> RcpdConfig {
747 RcpdConfig {
748 verbose: 0,
749 fail_early: false,
750 max_workers: 0,
751 max_blocking_threads: 0,
752 max_open_files: None,
753 ops_throttle: 0,
754 iops_throttle: 0,
755 chunk_size: 0,
756 auto_meta: None,
757 auto_meta_histogram: false,
758 auto_meta_histogram_log: None,
759 auto_meta_histogram_interval: std::time::Duration::from_secs(1),
760 dereference: false,
761 overwrite: false,
762 overwrite_compare: "size,mtime".to_string(),
763 overwrite_filter: None,
764 ignore_existing: false,
765 skip_specials: false,
766 debug_log_prefix: None,
767 port_ranges: None,
768 progress: false,
769 progress_delay: None,
770 remote_copy_conn_timeout_sec: 30,
771 network_profile: crate::NetworkProfile::default(),
772 buffer_size: None,
773 max_connections: 1,
774 pending_writes_multiplier: 1,
775 chrome_trace_prefix: None,
776 flamegraph_prefix: None,
777 profile_level: None,
778 tokio_console: false,
779 tokio_console_port: None,
780 encryption: true,
781 master_cert_fingerprint: None,
782 overwrite_manifest_max_entries: DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES,
783 }
784 }
785
786 #[test]
787 fn to_args_includes_overwrite_manifest_max_entries() {
788 let mut config = minimal_rcpd_config();
789 config.overwrite_manifest_max_entries = 123_456;
790 let args = config.to_args();
791 assert!(
792 args.iter()
793 .any(|a| a == "--overwrite-manifest-max-entries=123456"),
794 "expected manifest cap flag in {args:?}"
795 );
796 }
797
798 #[test]
799 fn to_args_omits_auto_meta_throttle_when_none() {
800 let args = minimal_rcpd_config().to_args();
801 let throttle_flags = [
803 "--auto-meta-throttle",
804 "--auto-meta-initial-cwnd",
805 "--auto-meta-min-cwnd",
806 "--auto-meta-max-cwnd",
807 "--auto-meta-alpha",
808 "--auto-meta-beta",
809 "--auto-meta-baseline-percentile",
810 "--auto-meta-current-percentile",
811 "--auto-meta-increase-step",
812 "--auto-meta-decrease-step",
813 "--auto-meta-long-window",
814 "--auto-meta-short-window",
815 "--auto-meta-tick-interval",
816 ];
817 for flag in throttle_flags {
818 assert!(
819 !args.iter().any(|a| a.starts_with(flag)),
820 "throttle flag {flag} should not be emitted when auto_meta is None: {args:?}",
821 );
822 }
823 for arg in &args {
825 assert!(
826 !arg.starts_with("--auto-meta-histogram"),
827 "must not emit any histogram flag when histograms are off, found: {arg}",
828 );
829 }
830 }
831
832 #[test]
833 fn to_args_propagates_all_auto_meta_fields() {
834 let mut config = minimal_rcpd_config();
835 config.auto_meta = Some(common::AutoMetaThrottleConfig {
836 initial_cwnd: 8,
837 min_cwnd: 2,
838 max_cwnd: 128,
839 alpha: 1.2,
840 beta: 1.6,
841 increase_step: 2,
842 decrease_step: 3,
843 baseline_percentile: 0.4,
844 current_percentile: 0.6,
845 long_window: std::time::Duration::from_secs(20),
846 short_window: std::time::Duration::from_secs(2),
847 tick_interval: std::time::Duration::from_millis(75),
848 });
849 let args = config.to_args();
850 let has = |needle: &str| args.iter().any(|a| a == needle);
851 let has_prefix = |needle: &str| args.iter().any(|a| a.starts_with(needle));
852 assert!(has("--auto-meta-throttle"));
853 assert!(has("--auto-meta-initial-cwnd=8"));
854 assert!(has("--auto-meta-min-cwnd=2"));
855 assert!(has("--auto-meta-max-cwnd=128"));
856 assert!(has_prefix("--auto-meta-alpha=1.2"));
857 assert!(has_prefix("--auto-meta-beta=1.6"));
858 assert!(has_prefix("--auto-meta-baseline-percentile=0.4"));
859 assert!(has_prefix("--auto-meta-current-percentile=0.6"));
860 assert!(has("--auto-meta-increase-step=2"));
861 assert!(has("--auto-meta-decrease-step=3"));
862 assert!(has_prefix("--auto-meta-long-window="));
863 assert!(has_prefix("--auto-meta-short-window="));
864 assert!(has_prefix("--auto-meta-tick-interval="));
865 }
866
867 #[test]
868 fn to_args_omits_histogram_flags_when_disabled() {
869 let mut config = minimal_rcpd_config();
873 config.auto_meta_histogram = false;
874 config.auto_meta_histogram_log = None;
875 let args = config.to_args();
876 for arg in &args {
877 assert!(
878 !arg.starts_with("--auto-meta-histogram"),
879 "must not emit histogram flag when disabled, found: {arg}",
880 );
881 }
882 }
883
884 #[test]
885 fn to_args_omits_panel_only_flag_when_no_log_path() {
886 let mut config = minimal_rcpd_config();
890 config.auto_meta_histogram = true;
891 config.auto_meta_histogram_log = None;
892 let args = config.to_args();
893 for arg in &args {
894 assert!(
895 !arg.starts_with("--auto-meta-histogram"),
896 "panel-only flag must not be forwarded to rcpd, found: {arg}",
897 );
898 }
899 }
900
901 #[test]
902 fn to_args_forwards_histogram_log_and_interval_when_log_path_set() {
903 let mut config = minimal_rcpd_config();
904 config.auto_meta_histogram = false; config.auto_meta_histogram_log = Some("/tmp/foo.hdr".into());
906 config.auto_meta_histogram_interval = std::time::Duration::from_millis(500);
907 let args = config.to_args();
908 assert!(
909 args.iter()
910 .any(|a| a == "--auto-meta-histogram-log=/tmp/foo.hdr")
911 );
912 assert!(
913 args.iter()
914 .any(|a| a.starts_with("--auto-meta-histogram-interval="))
915 );
916 assert!(
919 !args.iter().any(|a| a == "--auto-meta-histogram"),
920 "panel-only flag must not be forwarded; the log flag implies the pipeline",
921 );
922 }
923}