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 require_toctou_safe: bool,
374 pub overwrite: bool,
375 pub overwrite_compare: String,
376 pub overwrite_manifest_max_entries: usize,
378 pub overwrite_filter: Option<String>,
379 pub ignore_existing: bool,
380 pub skip_specials: bool,
381 pub debug_log_prefix: Option<String>,
382 pub port_ranges: Option<String>,
384 pub progress: bool,
385 pub progress_delay: Option<String>,
386 pub remote_copy_conn_timeout_sec: u64,
387 pub network_profile: crate::NetworkProfile,
389 pub buffer_size: Option<usize>,
391 pub max_connections: usize,
393 pub pending_writes_multiplier: usize,
395 pub chrome_trace_prefix: Option<String>,
397 pub flamegraph_prefix: Option<String>,
399 pub profile_level: Option<String>,
401 pub tokio_console: bool,
403 pub tokio_console_port: Option<u16>,
405 pub encryption: bool,
407 pub master_cert_fingerprint: Option<CertFingerprint>,
409}
410
411impl RcpdConfig {
412 pub fn to_args(&self) -> Vec<String> {
413 let mut args = vec![
414 format!("--max-workers={}", self.max_workers),
415 format!("--max-blocking-threads={}", self.max_blocking_threads),
416 format!("--ops-throttle={}", self.ops_throttle),
417 format!("--iops-throttle={}", self.iops_throttle),
418 format!("--chunk-size={}", self.chunk_size),
419 format!("--overwrite-compare={}", self.overwrite_compare),
420 format!(
421 "--overwrite-manifest-max-entries={}",
422 self.overwrite_manifest_max_entries
423 ),
424 ];
425 if self.verbose > 0 {
426 args.push(format!("-{}", "v".repeat(self.verbose as usize)));
427 }
428 if self.fail_early {
429 args.push("--fail-early".to_string());
430 }
431 if let Some(v) = self.max_open_files {
432 args.push(format!("--max-open-files={v}"));
433 }
434 if self.dereference {
435 args.push("--dereference".to_string());
436 }
437 if self.require_toctou_safe {
438 args.push("--require-toctou-safe".to_string());
439 }
440 if self.overwrite {
441 args.push("--overwrite".to_string());
442 if let Some(ref filter) = self.overwrite_filter {
443 args.push(format!("--overwrite-filter={filter}"));
444 }
445 }
446 if self.ignore_existing {
447 args.push("--ignore-existing".to_string());
448 }
449 if self.skip_specials {
450 args.push("--skip-specials".to_string());
451 }
452 if let Some(ref prefix) = self.debug_log_prefix {
453 args.push(format!("--debug-log-prefix={prefix}"));
454 }
455 if let Some(ref ranges) = self.port_ranges {
456 args.push(format!("--port-ranges={ranges}"));
457 }
458 if self.progress {
459 args.push("--progress".to_string());
460 }
461 if let Some(ref delay) = self.progress_delay {
462 args.push(format!("--progress-delay={delay}"));
463 }
464 args.push(format!(
465 "--remote-copy-conn-timeout-sec={}",
466 self.remote_copy_conn_timeout_sec
467 ));
468 args.push(format!("--network-profile={}", self.network_profile));
470 if let Some(v) = self.buffer_size {
472 args.push(format!("--buffer-size={v}"));
473 }
474 args.push(format!("--max-connections={}", self.max_connections));
475 args.push(format!(
476 "--pending-writes-multiplier={}",
477 self.pending_writes_multiplier
478 ));
479 let profiling_enabled =
481 self.chrome_trace_prefix.is_some() || self.flamegraph_prefix.is_some();
482 if let Some(ref prefix) = self.chrome_trace_prefix {
483 args.push(format!("--chrome-trace={prefix}"));
484 }
485 if let Some(ref prefix) = self.flamegraph_prefix {
486 args.push(format!("--flamegraph={prefix}"));
487 }
488 if profiling_enabled && let Some(level) = &self.profile_level {
489 args.push(format!("--profile-level={level}"));
490 }
491 if self.tokio_console {
492 args.push("--tokio-console".to_string());
493 }
494 if let Some(port) = self.tokio_console_port {
495 args.push(format!("--tokio-console-port={port}"));
496 }
497 if !self.encryption {
498 args.push("--no-encryption".to_string());
499 }
500 if let Some(fp) = self.master_cert_fingerprint {
501 args.push(format!(
502 "--master-cert-fp={}",
503 crate::tls::fingerprint_to_hex(&fp)
504 ));
505 }
506 if let Some(auto) = &self.auto_meta {
509 args.push("--auto-meta-throttle".to_string());
510 args.push(format!("--auto-meta-initial-cwnd={}", auto.initial_cwnd));
511 args.push(format!("--auto-meta-min-cwnd={}", auto.min_cwnd));
512 args.push(format!("--auto-meta-max-cwnd={}", auto.max_cwnd));
513 args.push(format!("--auto-meta-alpha={}", auto.alpha));
514 args.push(format!("--auto-meta-beta={}", auto.beta));
515 args.push(format!(
516 "--auto-meta-baseline-percentile={}",
517 auto.baseline_percentile,
518 ));
519 args.push(format!(
520 "--auto-meta-current-percentile={}",
521 auto.current_percentile,
522 ));
523 args.push(format!("--auto-meta-increase-step={}", auto.increase_step));
524 args.push(format!("--auto-meta-decrease-step={}", auto.decrease_step));
525 args.push(format!(
526 "--auto-meta-long-window={}",
527 humantime::format_duration(auto.long_window),
528 ));
529 args.push(format!(
530 "--auto-meta-short-window={}",
531 humantime::format_duration(auto.short_window),
532 ));
533 args.push(format!(
534 "--auto-meta-tick-interval={}",
535 humantime::format_duration(auto.tick_interval),
536 ));
537 }
538 if let Some(path) = &self.auto_meta_histogram_log {
546 args.push(format!("--auto-meta-histogram-log={path}"));
547 args.push(format!(
548 "--auto-meta-histogram-interval={}",
549 humantime::format_duration(self.auto_meta_histogram_interval),
550 ));
551 }
552 args
553 }
554}
555
556#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
557pub enum RcpdRole {
558 Source,
559 Destination,
560}
561
562impl std::fmt::Display for RcpdRole {
563 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564 match self {
565 RcpdRole::Source => write!(f, "source"),
566 RcpdRole::Destination => write!(f, "destination"),
567 }
568 }
569}
570
571impl std::str::FromStr for RcpdRole {
572 type Err = anyhow::Error;
573 fn from_str(s: &str) -> Result<Self, Self::Err> {
574 match s.to_lowercase().as_str() {
575 "source" => Ok(RcpdRole::Source),
576 "destination" | "dest" => Ok(RcpdRole::Destination),
577 _ => Err(anyhow::anyhow!("invalid role: {}", s)),
578 }
579 }
580}
581
582#[derive(Clone, Debug, Deserialize, Serialize)]
583pub struct TracingHello {
584 pub role: RcpdRole,
585 pub is_tracing: bool,
587}
588
589pub type CertFingerprint = [u8; 32];
591
592#[derive(Clone, Debug, Deserialize, Serialize)]
593pub enum MasterHello {
594 Source {
595 src: std::path::PathBuf,
596 dst: std::path::PathBuf,
597 dest_cert_fingerprint: Option<CertFingerprint>,
599 filter: Option<common::filter::FilterSettings>,
601 dry_run: Option<common::config::DryRunMode>,
603 },
604 Destination {
605 source_control_addr: std::net::SocketAddr,
607 source_data_addr: std::net::SocketAddr,
609 server_name: String,
610 preserve: common::preserve::Settings,
611 source_cert_fingerprint: Option<CertFingerprint>,
613 },
614}
615
616#[derive(Clone, Debug, Deserialize, Serialize)]
617pub struct SourceMasterHello {
618 pub control_addr: std::net::SocketAddr,
620 pub data_addr: std::net::SocketAddr,
622 pub server_name: String,
623}
624
625pub use common::RuntimeStats;
627
628#[derive(Clone, Debug, Deserialize, Serialize)]
629pub enum RcpdResult {
630 Success {
631 message: String,
632 summary: common::copy::Summary,
633 runtime_stats: common::RuntimeStats,
634 },
635 Failure {
636 error: String,
637 summary: common::copy::Summary,
638 runtime_stats: common::RuntimeStats,
639 },
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn mk_entry(name: &str) -> ExistingEntry {
647 ExistingEntry {
648 name: std::path::PathBuf::from(name),
649 is_file: true,
650 metadata: Metadata {
651 mode: 0o644,
652 uid: 0,
653 gid: 0,
654 atime: 0,
655 mtime: 0,
656 atime_nsec: 0,
657 mtime_nsec: 0,
658 },
659 size: 0,
660 }
661 }
662
663 #[test]
664 fn chunk_manifest_empty_yields_no_chunks() {
665 assert!(chunk_manifest(vec![], MANIFEST_CHUNK_BYTE_BUDGET).is_empty());
666 }
667
668 #[test]
669 fn chunk_manifest_small_is_single_chunk() {
670 let entries: Vec<_> = (0..100).map(|i| mk_entry(&format!("f{i}.txt"))).collect();
671 let chunks = chunk_manifest(entries, MANIFEST_CHUNK_BYTE_BUDGET);
672 assert_eq!(chunks.len(), 1);
673 assert_eq!(chunks[0].len(), 100);
674 }
675
676 #[test]
677 fn chunk_manifest_splits_and_preserves_all_entries_in_order() {
678 let entries: Vec<_> = (0..1000)
679 .map(|i| mk_entry(&format!("file_{i:04}.dat")))
680 .collect();
681 let chunks = chunk_manifest(entries.clone(), 256);
683 assert!(
684 chunks.len() > 1,
685 "tiny budget should produce multiple chunks"
686 );
687 for chunk in &chunks {
690 if chunk.len() > 1 {
691 let total: usize = chunk.iter().map(estimate_entry_size).sum();
692 assert!(total <= 256, "multi-entry chunk exceeds budget: {total}");
693 }
694 }
695 let flat: Vec<_> = chunks.into_iter().flatten().collect();
697 assert_eq!(flat.len(), entries.len());
698 for (got, want) in flat.iter().zip(entries.iter()) {
699 assert_eq!(got.name, want.name);
700 }
701 }
702
703 #[test]
704 fn chunk_manifest_entry_larger_than_budget_gets_its_own_chunk() {
705 let chunks = chunk_manifest(vec![mk_entry("a"), mk_entry("b")], 1);
707 assert_eq!(chunks.len(), 2);
708 assert_eq!(chunks[0].len(), 1);
709 assert_eq!(chunks[1].len(), 1);
710 }
711
712 #[test]
713 fn chunk_manifest_splits_a_large_manifest_at_the_production_budget() {
714 let per_entry = estimate_entry_size(&mk_entry("file_0000000.dat"));
719 let n = (MANIFEST_CHUNK_BYTE_BUDGET / per_entry) * 2 + 1000;
720 let entries: Vec<_> = (0..n)
721 .map(|i| mk_entry(&format!("file_{i:07}.dat")))
722 .collect();
723 let chunks = chunk_manifest(entries.clone(), MANIFEST_CHUNK_BYTE_BUDGET);
724 assert!(
725 chunks.len() > 1,
726 "a manifest larger than the 4 MiB budget must span multiple chunks, got {}",
727 chunks.len()
728 );
729 for chunk in &chunks {
731 if chunk.len() > 1 {
732 let total: usize = chunk.iter().map(estimate_entry_size).sum();
733 assert!(
734 total <= MANIFEST_CHUNK_BYTE_BUDGET,
735 "a chunk exceeds the production budget: {total}"
736 );
737 }
738 }
739 let flat: Vec<_> = chunks.into_iter().flatten().collect();
741 assert_eq!(
742 flat.len(),
743 entries.len(),
744 "reassembly must preserve every entry"
745 );
746 assert!(
747 flat.iter().zip(&entries).all(|(a, b)| a.name == b.name),
748 "reassembly must preserve order"
749 );
750 }
751
752 fn minimal_rcpd_config() -> RcpdConfig {
753 RcpdConfig {
754 verbose: 0,
755 fail_early: false,
756 max_workers: 0,
757 max_blocking_threads: 0,
758 max_open_files: None,
759 ops_throttle: 0,
760 iops_throttle: 0,
761 chunk_size: 0,
762 auto_meta: None,
763 auto_meta_histogram: false,
764 auto_meta_histogram_log: None,
765 auto_meta_histogram_interval: std::time::Duration::from_secs(1),
766 dereference: false,
767 require_toctou_safe: false,
768 overwrite: false,
769 overwrite_compare: "size,mtime".to_string(),
770 overwrite_filter: None,
771 ignore_existing: false,
772 skip_specials: false,
773 debug_log_prefix: None,
774 port_ranges: None,
775 progress: false,
776 progress_delay: None,
777 remote_copy_conn_timeout_sec: 30,
778 network_profile: crate::NetworkProfile::default(),
779 buffer_size: None,
780 max_connections: 1,
781 pending_writes_multiplier: 1,
782 chrome_trace_prefix: None,
783 flamegraph_prefix: None,
784 profile_level: None,
785 tokio_console: false,
786 tokio_console_port: None,
787 encryption: true,
788 master_cert_fingerprint: None,
789 overwrite_manifest_max_entries: DEFAULT_OVERWRITE_MANIFEST_MAX_ENTRIES,
790 }
791 }
792
793 #[test]
794 fn to_args_includes_overwrite_manifest_max_entries() {
795 let mut config = minimal_rcpd_config();
796 config.overwrite_manifest_max_entries = 123_456;
797 let args = config.to_args();
798 assert!(
799 args.iter()
800 .any(|a| a == "--overwrite-manifest-max-entries=123456"),
801 "expected manifest cap flag in {args:?}"
802 );
803 }
804
805 #[test]
806 fn to_args_mirrors_require_toctou_safe() {
807 let mut config = minimal_rcpd_config();
808 config.require_toctou_safe = true;
809 let args = config.to_args();
810 assert!(
811 args.iter().any(|a| a == "--require-toctou-safe"),
812 "expected --require-toctou-safe in {args:?}"
813 );
814 let config = minimal_rcpd_config();
815 assert!(
816 !config
817 .to_args()
818 .iter()
819 .any(|a| a == "--require-toctou-safe"),
820 "flag must be omitted when off"
821 );
822 }
823
824 #[test]
825 fn to_args_omits_auto_meta_throttle_when_none() {
826 let args = minimal_rcpd_config().to_args();
827 let throttle_flags = [
829 "--auto-meta-throttle",
830 "--auto-meta-initial-cwnd",
831 "--auto-meta-min-cwnd",
832 "--auto-meta-max-cwnd",
833 "--auto-meta-alpha",
834 "--auto-meta-beta",
835 "--auto-meta-baseline-percentile",
836 "--auto-meta-current-percentile",
837 "--auto-meta-increase-step",
838 "--auto-meta-decrease-step",
839 "--auto-meta-long-window",
840 "--auto-meta-short-window",
841 "--auto-meta-tick-interval",
842 ];
843 for flag in throttle_flags {
844 assert!(
845 !args.iter().any(|a| a.starts_with(flag)),
846 "throttle flag {flag} should not be emitted when auto_meta is None: {args:?}",
847 );
848 }
849 for arg in &args {
851 assert!(
852 !arg.starts_with("--auto-meta-histogram"),
853 "must not emit any histogram flag when histograms are off, found: {arg}",
854 );
855 }
856 }
857
858 #[test]
859 fn to_args_propagates_all_auto_meta_fields() {
860 let mut config = minimal_rcpd_config();
861 config.auto_meta = Some(common::AutoMetaThrottleConfig {
862 initial_cwnd: 8,
863 min_cwnd: 2,
864 max_cwnd: 128,
865 alpha: 1.2,
866 beta: 1.6,
867 increase_step: 2,
868 decrease_step: 3,
869 baseline_percentile: 0.4,
870 current_percentile: 0.6,
871 long_window: std::time::Duration::from_secs(20),
872 short_window: std::time::Duration::from_secs(2),
873 tick_interval: std::time::Duration::from_millis(75),
874 });
875 let args = config.to_args();
876 let has = |needle: &str| args.iter().any(|a| a == needle);
877 let has_prefix = |needle: &str| args.iter().any(|a| a.starts_with(needle));
878 assert!(has("--auto-meta-throttle"));
879 assert!(has("--auto-meta-initial-cwnd=8"));
880 assert!(has("--auto-meta-min-cwnd=2"));
881 assert!(has("--auto-meta-max-cwnd=128"));
882 assert!(has_prefix("--auto-meta-alpha=1.2"));
883 assert!(has_prefix("--auto-meta-beta=1.6"));
884 assert!(has_prefix("--auto-meta-baseline-percentile=0.4"));
885 assert!(has_prefix("--auto-meta-current-percentile=0.6"));
886 assert!(has("--auto-meta-increase-step=2"));
887 assert!(has("--auto-meta-decrease-step=3"));
888 assert!(has_prefix("--auto-meta-long-window="));
889 assert!(has_prefix("--auto-meta-short-window="));
890 assert!(has_prefix("--auto-meta-tick-interval="));
891 }
892
893 #[test]
894 fn to_args_omits_histogram_flags_when_disabled() {
895 let mut config = minimal_rcpd_config();
899 config.auto_meta_histogram = false;
900 config.auto_meta_histogram_log = None;
901 let args = config.to_args();
902 for arg in &args {
903 assert!(
904 !arg.starts_with("--auto-meta-histogram"),
905 "must not emit histogram flag when disabled, found: {arg}",
906 );
907 }
908 }
909
910 #[test]
911 fn to_args_omits_panel_only_flag_when_no_log_path() {
912 let mut config = minimal_rcpd_config();
916 config.auto_meta_histogram = true;
917 config.auto_meta_histogram_log = None;
918 let args = config.to_args();
919 for arg in &args {
920 assert!(
921 !arg.starts_with("--auto-meta-histogram"),
922 "panel-only flag must not be forwarded to rcpd, found: {arg}",
923 );
924 }
925 }
926
927 #[test]
928 fn to_args_forwards_histogram_log_and_interval_when_log_path_set() {
929 let mut config = minimal_rcpd_config();
930 config.auto_meta_histogram = false; config.auto_meta_histogram_log = Some("/tmp/foo.hdr".into());
932 config.auto_meta_histogram_interval = std::time::Duration::from_millis(500);
933 let args = config.to_args();
934 assert!(
935 args.iter()
936 .any(|a| a == "--auto-meta-histogram-log=/tmp/foo.hdr")
937 );
938 assert!(
939 args.iter()
940 .any(|a| a.starts_with("--auto-meta-histogram-interval="))
941 );
942 assert!(
945 !args.iter().any(|a| a == "--auto-meta-histogram"),
946 "panel-only flag must not be forwarded; the log flag implies the pipeline",
947 );
948 }
949}