Skip to main content

shipper_config/runtime/
mod.rs

1//! Conversion layer from `shipper_config` model types to shared `shipper_types`.
2//!
3//! This module isolates config/runtime mapping so that callers can reuse a
4//! single conversion surface instead of duplicating this logic.
5//!
6//! Was previously the standalone crate `shipper-config-runtime`; absorbed into
7//! `shipper-config::runtime` (Phase 5 of the decrating effort).
8
9use crate::RuntimeOptions;
10
11/// Convert a `shipper_config::RuntimeOptions` value into `shipper_types::RuntimeOptions`.
12///
13/// This keeps the mapping in one place and allows downstream crates to consume a
14/// stable contract without duplicating conversion logic.
15pub fn into_runtime_options(value: RuntimeOptions) -> shipper_types::RuntimeOptions {
16    shipper_types::RuntimeOptions {
17        allow_dirty: value.allow_dirty,
18        skip_ownership_check: value.skip_ownership_check,
19        strict_ownership: value.strict_ownership,
20        no_verify: value.no_verify,
21        max_attempts: value.max_attempts,
22        base_delay: value.base_delay,
23        max_delay: value.max_delay,
24        retry_strategy: value.retry_strategy,
25        retry_jitter: value.retry_jitter,
26        retry_per_error: value.retry_per_error,
27        verify_timeout: value.verify_timeout,
28        verify_poll_interval: value.verify_poll_interval,
29        state_dir: value.state_dir,
30        force_resume: value.force_resume,
31        policy: value.policy,
32        verify_mode: value.verify_mode,
33        readiness: value.readiness,
34        output_lines: value.output_lines,
35        force: value.force,
36        lock_timeout: value.lock_timeout,
37        parallel: value.parallel,
38        webhook: value.webhook,
39        encryption: value.encryption,
40        registries: value.registries,
41        resume_from: value.resume_from,
42        rehearsal_registry: value.rehearsal_registry,
43        rehearsal_skip: value.rehearsal_skip,
44        rehearsal_smoke_install: value.rehearsal_smoke_install,
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use crate::{
52        EncryptionConfig, ParallelConfig, PublishPolicy, ReadinessConfig, ReadinessMethod,
53        Registry, VerifyMode, WebhookConfig,
54    };
55    use proptest::prelude::*;
56    use shipper_types as expected_types;
57    use std::path::PathBuf;
58    use std::time::Duration;
59
60    fn sample_runtime_options() -> RuntimeOptions {
61        RuntimeOptions {
62            allow_dirty: true,
63            skip_ownership_check: false,
64            strict_ownership: true,
65            no_verify: false,
66            max_attempts: 8,
67            base_delay: Duration::from_secs(2),
68            max_delay: Duration::from_secs(45),
69            retry_strategy: shipper_retry::RetryStrategyType::Exponential,
70            retry_jitter: 0.25,
71            retry_per_error: shipper_retry::PerErrorConfig::default(),
72            verify_timeout: Duration::from_secs(120),
73            verify_poll_interval: Duration::from_secs(3),
74            state_dir: PathBuf::from("target/.shipper-tests"),
75            force_resume: false,
76            policy: PublishPolicy::Balanced,
77            verify_mode: VerifyMode::Package,
78            readiness: ReadinessConfig {
79                enabled: true,
80                method: ReadinessMethod::Both,
81                initial_delay: Duration::from_millis(150),
82                max_delay: Duration::from_secs(30),
83                max_total_wait: Duration::from_secs(300),
84                poll_interval: Duration::from_secs(3),
85                jitter_factor: 0.4,
86                index_path: Some(PathBuf::from("ci-index")),
87                prefer_index: true,
88            },
89            output_lines: 777,
90            force: true,
91            lock_timeout: Duration::from_secs(4_800),
92            parallel: ParallelConfig {
93                enabled: true,
94                max_concurrent: 6,
95                per_package_timeout: Duration::from_secs(180),
96            },
97            webhook: WebhookConfig {
98                url: "https://example.internal/webhook".to_string(),
99                secret: Some("shh".to_string()),
100                timeout_secs: 15,
101                ..WebhookConfig::default()
102            },
103            encryption: EncryptionConfig {
104                enabled: true,
105                passphrase: Some("password".to_string()),
106                env_var: Some("SHIPPER_ENCRYPT_KEY".to_string()),
107            },
108            registries: vec![
109                Registry {
110                    name: "crates-io".to_string(),
111                    api_base: "https://crates.io".to_string(),
112                    index_base: Some("https://index.crates.io".to_string()),
113                },
114                Registry {
115                    name: "mirror".to_string(),
116                    api_base: "https://mirror.example.local".to_string(),
117                    index_base: None,
118                },
119            ],
120            resume_from: Some("my-crate".to_string()),
121            rehearsal_registry: None,
122            rehearsal_skip: false,
123            rehearsal_smoke_install: None,
124        }
125    }
126
127    #[test]
128    fn maps_simple_discriminants() {
129        assert_eq!(PublishPolicy::Fast, expected_types::PublishPolicy::Fast);
130        assert_eq!(VerifyMode::Package, expected_types::VerifyMode::Package);
131        assert_eq!(
132            ReadinessMethod::Index,
133            expected_types::ReadinessMethod::Index
134        );
135    }
136
137    #[test]
138    fn maps_nested_structures_and_webhook_payload_fields() {
139        let source = sample_runtime_options();
140        let converted = into_runtime_options(source);
141
142        assert_eq!(converted.policy, expected_types::PublishPolicy::Balanced);
143        assert_eq!(converted.verify_mode, expected_types::VerifyMode::Package);
144        assert_eq!(
145            converted.readiness.method,
146            expected_types::ReadinessMethod::Both
147        );
148        assert_eq!(converted.parallel.max_concurrent, 6);
149        assert_eq!(converted.webhook.url, "https://example.internal/webhook");
150        assert_eq!(converted.webhook.secret.as_deref(), Some("shh"));
151        assert_eq!(converted.webhook.timeout_secs, 15);
152        assert!(converted.encryption.enabled);
153        assert_eq!(converted.encryption.passphrase.as_deref(), Some("password"));
154        assert_eq!(converted.registries.len(), 2);
155    }
156
157    #[test]
158    fn maps_readiness_config_fields() {
159        let converted = sample_runtime_options().readiness;
160
161        assert!(converted.enabled);
162        assert!(converted.prefer_index);
163        assert_eq!(
164            converted.index_path.as_deref(),
165            Some(std::path::Path::new("ci-index"))
166        );
167    }
168
169    #[test]
170    fn maps_parallel_config() {
171        let converted = sample_runtime_options().parallel;
172
173        assert!(converted.enabled);
174        assert_eq!(converted.max_concurrent, 6);
175        assert_eq!(converted.per_package_timeout, Duration::from_secs(180));
176    }
177
178    #[test]
179    fn maps_registry() {
180        let converted = sample_runtime_options().registries[0].clone();
181
182        assert_eq!(converted.name, "crates-io");
183        assert_eq!(converted.api_base, "https://crates.io");
184    }
185
186    fn registry_count_strategy() -> impl Strategy<Value = usize> {
187        0usize..4usize
188    }
189
190    fn webhook_url_strategy() -> impl Strategy<Value = String> {
191        prop::collection::vec(prop::char::range('a', 'z'), 0..32)
192            .prop_map(|chars| chars.into_iter().collect())
193    }
194
195    proptest! {
196        #[test]
197        fn fuzz_like_values_roundtrip_without_panic(
198            allow_dirty in any::<bool>(),
199            skip_ownership_check in any::<bool>(),
200            strict_ownership in any::<bool>(),
201            no_verify in any::<bool>(),
202            max_attempts in 1u32..20,
203            base_delay_ms in 0u64..5_000,
204            max_delay_ms in 0u64..10_000,
205            output_lines in 1usize..2000,
206            policy in prop_oneof![
207                Just(PublishPolicy::Safe),
208                Just(PublishPolicy::Balanced),
209                Just(PublishPolicy::Fast),
210            ],
211            verify_mode in prop_oneof![
212                Just(VerifyMode::Workspace),
213                Just(VerifyMode::Package),
214                Just(VerifyMode::None),
215            ],
216            readiness_method in prop_oneof![
217                Just(ReadinessMethod::Api),
218                Just(ReadinessMethod::Index),
219                Just(ReadinessMethod::Both),
220            ],
221            webhook_url in webhook_url_strategy(),
222            use_secret in any::<bool>(),
223            registry_count in registry_count_strategy(),
224        ) {
225            let webhook = WebhookConfig {
226                url: webhook_url.clone(),
227                secret: if use_secret { Some("secret".to_string()) } else { None },
228                ..WebhookConfig::default()
229            };
230
231            let encryption = EncryptionConfig {
232                enabled: true,
233                passphrase: if use_secret { Some("secret-pass".to_string()) } else { None },
234                ..EncryptionConfig::default()
235            };
236
237            let registries = (0..registry_count)
238                .map(|idx| Registry {
239                    name: format!("r-{idx}"),
240                    api_base: format!("https://registry{idx}.example"),
241                    index_base: Some(format!("https://registry{idx}.example/index")),
242                })
243                .collect();
244
245            let input = RuntimeOptions {
246                allow_dirty,
247                skip_ownership_check,
248                strict_ownership,
249                no_verify,
250                max_attempts,
251                base_delay: Duration::from_millis(base_delay_ms),
252                max_delay: Duration::from_millis(max_delay_ms.max(base_delay_ms + 1)),
253                retry_strategy: shipper_retry::RetryStrategyType::Exponential,
254                retry_jitter: 0.25,
255                retry_per_error: shipper_retry::PerErrorConfig::default(),
256                verify_timeout: Duration::from_secs(30),
257                verify_poll_interval: Duration::from_secs(1),
258                state_dir: PathBuf::from(".shipper"),
259                force_resume: false,
260                policy,
261                verify_mode,
262                readiness: ReadinessConfig {
263                    enabled: true,
264                    method: readiness_method,
265                    initial_delay: Duration::from_millis(10),
266                    max_delay: Duration::from_secs(1),
267                    max_total_wait: Duration::from_secs(60),
268                    poll_interval: Duration::from_millis(250),
269                    jitter_factor: 0.4,
270                    index_path: None,
271                    prefer_index: false,
272                },
273                output_lines,
274                force: false,
275                lock_timeout: Duration::from_secs(300),
276                parallel: ParallelConfig {
277                    enabled: true,
278                    max_concurrent: 4,
279                    per_package_timeout: Duration::from_secs(120),
280                },
281                webhook,
282                encryption,
283                registries,
284                resume_from: None,
285            rehearsal_registry: None,
286            rehearsal_skip: false,
287            rehearsal_smoke_install: None,
288            };
289
290            let converted = into_runtime_options(input);
291
292            prop_assert_eq!(converted.allow_dirty, allow_dirty);
293            prop_assert_eq!(converted.skip_ownership_check, skip_ownership_check);
294            prop_assert_eq!(converted.strict_ownership, strict_ownership);
295            prop_assert_eq!(converted.no_verify, no_verify);
296            prop_assert_eq!(converted.max_attempts, max_attempts);
297            prop_assert_eq!(converted.policy, policy);
298            prop_assert_eq!(converted.verify_mode, verify_mode);
299            prop_assert!(converted.readiness.enabled);
300            prop_assert_eq!(converted.readiness.method, readiness_method);
301            prop_assert_eq!(converted.webhook.url, webhook_url);
302            prop_assert_eq!(converted.webhook.secret.is_some(), use_secret);
303            prop_assert_eq!(converted.registries.len(), registry_count);
304        }
305    }
306
307    // ── Edge case: empty registries list ──────────────────────────────
308    #[test]
309    fn empty_registries_list() {
310        let mut opts = sample_runtime_options();
311        opts.registries = vec![];
312        let converted = into_runtime_options(opts);
313        assert!(converted.registries.is_empty());
314    }
315
316    // ── Edge case: None optionals ──────────────────────────────────────
317    #[test]
318    fn none_webhook_secret() {
319        let mut opts = sample_runtime_options();
320        opts.webhook.secret = None;
321        let converted = into_runtime_options(opts);
322        assert!(converted.webhook.secret.is_none());
323    }
324
325    #[test]
326    fn none_encryption_passphrase() {
327        let mut opts = sample_runtime_options();
328        opts.encryption.passphrase = None;
329        let converted = into_runtime_options(opts);
330        assert!(converted.encryption.passphrase.is_none());
331    }
332
333    #[test]
334    fn none_encryption_env_var() {
335        let mut opts = sample_runtime_options();
336        opts.encryption.env_var = None;
337        let converted = into_runtime_options(opts);
338        assert!(converted.encryption.env_var.is_none());
339    }
340
341    #[test]
342    fn none_resume_from() {
343        let mut opts = sample_runtime_options();
344        opts.resume_from = None;
345        let converted = into_runtime_options(opts);
346        assert!(converted.resume_from.is_none());
347    }
348
349    #[test]
350    fn none_index_path() {
351        let mut opts = sample_runtime_options();
352        opts.readiness.index_path = None;
353        let converted = into_runtime_options(opts);
354        assert!(converted.readiness.index_path.is_none());
355    }
356
357    #[test]
358    fn none_index_base_in_registry() {
359        let mut opts = sample_runtime_options();
360        opts.registries = vec![Registry {
361            name: "test".to_string(),
362            api_base: "https://example.com".to_string(),
363            index_base: None,
364        }];
365        let converted = into_runtime_options(opts);
366        assert!(converted.registries[0].index_base.is_none());
367    }
368
369    // ── Duration boundary cases ────────────────────────────────────────
370    #[test]
371    fn zero_duration_base_delay() {
372        let mut opts = sample_runtime_options();
373        opts.base_delay = Duration::ZERO;
374        let converted = into_runtime_options(opts);
375        assert_eq!(converted.base_delay, Duration::ZERO);
376    }
377
378    #[test]
379    fn zero_duration_max_delay() {
380        let mut opts = sample_runtime_options();
381        opts.max_delay = Duration::ZERO;
382        let converted = into_runtime_options(opts);
383        assert_eq!(converted.max_delay, Duration::ZERO);
384    }
385
386    #[test]
387    fn zero_duration_verify_timeout() {
388        let mut opts = sample_runtime_options();
389        opts.verify_timeout = Duration::ZERO;
390        let converted = into_runtime_options(opts);
391        assert_eq!(converted.verify_timeout, Duration::ZERO);
392    }
393
394    #[test]
395    fn zero_duration_lock_timeout() {
396        let mut opts = sample_runtime_options();
397        opts.lock_timeout = Duration::ZERO;
398        let converted = into_runtime_options(opts);
399        assert_eq!(converted.lock_timeout, Duration::ZERO);
400    }
401
402    #[test]
403    fn very_small_duration_one_nanosecond() {
404        let mut opts = sample_runtime_options();
405        opts.base_delay = Duration::from_nanos(1);
406        opts.verify_poll_interval = Duration::from_nanos(1);
407        let converted = into_runtime_options(opts);
408        assert_eq!(converted.base_delay, Duration::from_nanos(1));
409        assert_eq!(converted.verify_poll_interval, Duration::from_nanos(1));
410    }
411
412    #[test]
413    fn very_large_duration() {
414        let large = Duration::from_secs(u64::MAX / 2);
415        let mut opts = sample_runtime_options();
416        opts.max_delay = large;
417        opts.lock_timeout = large;
418        let converted = into_runtime_options(opts);
419        assert_eq!(converted.max_delay, large);
420        assert_eq!(converted.lock_timeout, large);
421    }
422
423    #[test]
424    fn sub_millisecond_readiness_delays() {
425        let mut opts = sample_runtime_options();
426        opts.readiness.initial_delay = Duration::from_micros(500);
427        opts.readiness.poll_interval = Duration::from_micros(100);
428        let converted = into_runtime_options(opts);
429        assert_eq!(
430            converted.readiness.initial_delay,
431            Duration::from_micros(500)
432        );
433        assert_eq!(
434            converted.readiness.poll_interval,
435            Duration::from_micros(100)
436        );
437    }
438
439    #[test]
440    fn zero_per_package_timeout() {
441        let mut opts = sample_runtime_options();
442        opts.parallel.per_package_timeout = Duration::ZERO;
443        let converted = into_runtime_options(opts);
444        assert_eq!(converted.parallel.per_package_timeout, Duration::ZERO);
445    }
446
447    // ── Individual field mapping ───────────────────────────────────────
448    #[test]
449    fn maps_allow_dirty() {
450        for val in [true, false] {
451            let mut opts = sample_runtime_options();
452            opts.allow_dirty = val;
453            assert_eq!(into_runtime_options(opts).allow_dirty, val);
454        }
455    }
456
457    #[test]
458    fn maps_skip_ownership_check() {
459        for val in [true, false] {
460            let mut opts = sample_runtime_options();
461            opts.skip_ownership_check = val;
462            assert_eq!(into_runtime_options(opts).skip_ownership_check, val);
463        }
464    }
465
466    #[test]
467    fn maps_strict_ownership() {
468        for val in [true, false] {
469            let mut opts = sample_runtime_options();
470            opts.strict_ownership = val;
471            assert_eq!(into_runtime_options(opts).strict_ownership, val);
472        }
473    }
474
475    #[test]
476    fn maps_no_verify() {
477        for val in [true, false] {
478            let mut opts = sample_runtime_options();
479            opts.no_verify = val;
480            assert_eq!(into_runtime_options(opts).no_verify, val);
481        }
482    }
483
484    #[test]
485    fn maps_max_attempts() {
486        let mut opts = sample_runtime_options();
487        opts.max_attempts = 42;
488        assert_eq!(into_runtime_options(opts).max_attempts, 42);
489    }
490
491    #[test]
492    fn maps_base_delay() {
493        let mut opts = sample_runtime_options();
494        opts.base_delay = Duration::from_millis(999);
495        assert_eq!(
496            into_runtime_options(opts).base_delay,
497            Duration::from_millis(999)
498        );
499    }
500
501    #[test]
502    fn maps_max_delay() {
503        let mut opts = sample_runtime_options();
504        opts.max_delay = Duration::from_secs(9999);
505        assert_eq!(
506            into_runtime_options(opts).max_delay,
507            Duration::from_secs(9999)
508        );
509    }
510
511    #[test]
512    fn maps_retry_strategy() {
513        for strategy in [
514            shipper_retry::RetryStrategyType::Immediate,
515            shipper_retry::RetryStrategyType::Exponential,
516            shipper_retry::RetryStrategyType::Linear,
517            shipper_retry::RetryStrategyType::Constant,
518        ] {
519            let mut opts = sample_runtime_options();
520            opts.retry_strategy = strategy;
521            assert_eq!(into_runtime_options(opts).retry_strategy, strategy);
522        }
523    }
524
525    #[test]
526    fn maps_retry_jitter() {
527        let mut opts = sample_runtime_options();
528        opts.retry_jitter = 0.99;
529        let converted = into_runtime_options(opts);
530        assert!((converted.retry_jitter - 0.99).abs() < f64::EPSILON);
531    }
532
533    #[test]
534    fn maps_verify_timeout() {
535        let mut opts = sample_runtime_options();
536        opts.verify_timeout = Duration::from_secs(555);
537        assert_eq!(
538            into_runtime_options(opts).verify_timeout,
539            Duration::from_secs(555)
540        );
541    }
542
543    #[test]
544    fn maps_verify_poll_interval() {
545        let mut opts = sample_runtime_options();
546        opts.verify_poll_interval = Duration::from_millis(750);
547        assert_eq!(
548            into_runtime_options(opts).verify_poll_interval,
549            Duration::from_millis(750)
550        );
551    }
552
553    #[test]
554    fn maps_state_dir() {
555        let mut opts = sample_runtime_options();
556        opts.state_dir = PathBuf::from("/tmp/custom-state");
557        assert_eq!(
558            into_runtime_options(opts).state_dir,
559            PathBuf::from("/tmp/custom-state")
560        );
561    }
562
563    #[test]
564    fn maps_force_resume() {
565        for val in [true, false] {
566            let mut opts = sample_runtime_options();
567            opts.force_resume = val;
568            assert_eq!(into_runtime_options(opts).force_resume, val);
569        }
570    }
571
572    #[test]
573    fn maps_policy_variants() {
574        for policy in [
575            PublishPolicy::Safe,
576            PublishPolicy::Balanced,
577            PublishPolicy::Fast,
578        ] {
579            let mut opts = sample_runtime_options();
580            opts.policy = policy;
581            assert_eq!(into_runtime_options(opts).policy, policy);
582        }
583    }
584
585    #[test]
586    fn maps_verify_mode_variants() {
587        for mode in [VerifyMode::Workspace, VerifyMode::Package, VerifyMode::None] {
588            let mut opts = sample_runtime_options();
589            opts.verify_mode = mode;
590            assert_eq!(into_runtime_options(opts).verify_mode, mode);
591        }
592    }
593
594    #[test]
595    fn maps_output_lines() {
596        let mut opts = sample_runtime_options();
597        opts.output_lines = 0;
598        assert_eq!(into_runtime_options(opts).output_lines, 0);
599    }
600
601    #[test]
602    fn maps_force() {
603        for val in [true, false] {
604            let mut opts = sample_runtime_options();
605            opts.force = val;
606            assert_eq!(into_runtime_options(opts).force, val);
607        }
608    }
609
610    #[test]
611    fn maps_lock_timeout() {
612        let mut opts = sample_runtime_options();
613        opts.lock_timeout = Duration::from_secs(12345);
614        assert_eq!(
615            into_runtime_options(opts).lock_timeout,
616            Duration::from_secs(12345)
617        );
618    }
619
620    #[test]
621    fn maps_resume_from_some() {
622        let mut opts = sample_runtime_options();
623        opts.resume_from = Some("specific-crate".to_string());
624        assert_eq!(
625            into_runtime_options(opts).resume_from.as_deref(),
626            Some("specific-crate")
627        );
628    }
629
630    #[test]
631    fn maps_readiness_method_variants() {
632        for method in [
633            ReadinessMethod::Api,
634            ReadinessMethod::Index,
635            ReadinessMethod::Both,
636        ] {
637            let mut opts = sample_runtime_options();
638            opts.readiness.method = method;
639            assert_eq!(into_runtime_options(opts).readiness.method, method);
640        }
641    }
642
643    #[test]
644    fn maps_readiness_jitter_factor() {
645        let mut opts = sample_runtime_options();
646        opts.readiness.jitter_factor = 0.0;
647        let converted = into_runtime_options(opts);
648        assert!((converted.readiness.jitter_factor - 0.0).abs() < f64::EPSILON);
649    }
650
651    #[test]
652    fn maps_readiness_prefer_index() {
653        for val in [true, false] {
654            let mut opts = sample_runtime_options();
655            opts.readiness.prefer_index = val;
656            assert_eq!(into_runtime_options(opts).readiness.prefer_index, val);
657        }
658    }
659
660    #[test]
661    fn maps_parallel_max_concurrent() {
662        let mut opts = sample_runtime_options();
663        opts.parallel.max_concurrent = 1;
664        assert_eq!(into_runtime_options(opts).parallel.max_concurrent, 1);
665    }
666
667    #[test]
668    fn maps_parallel_enabled() {
669        for val in [true, false] {
670            let mut opts = sample_runtime_options();
671            opts.parallel.enabled = val;
672            assert_eq!(into_runtime_options(opts).parallel.enabled, val);
673        }
674    }
675
676    #[test]
677    fn maps_webhook_url() {
678        let mut opts = sample_runtime_options();
679        opts.webhook.url = "https://hooks.example.com/notify".to_string();
680        assert_eq!(
681            into_runtime_options(opts).webhook.url,
682            "https://hooks.example.com/notify"
683        );
684    }
685
686    #[test]
687    fn maps_webhook_timeout() {
688        let mut opts = sample_runtime_options();
689        opts.webhook.timeout_secs = 99;
690        assert_eq!(into_runtime_options(opts).webhook.timeout_secs, 99);
691    }
692
693    #[test]
694    fn maps_encryption_enabled() {
695        for val in [true, false] {
696            let mut opts = sample_runtime_options();
697            opts.encryption.enabled = val;
698            assert_eq!(into_runtime_options(opts).encryption.enabled, val);
699        }
700    }
701
702    // ── Special characters in URL/paths ────────────────────────────────
703    #[test]
704    fn special_chars_in_webhook_url() {
705        let mut opts = sample_runtime_options();
706        opts.webhook.url =
707            "https://hooks.example.com/path?key=val&foo=bar#fragment%20encoded".to_string();
708        assert_eq!(
709            into_runtime_options(opts).webhook.url,
710            "https://hooks.example.com/path?key=val&foo=bar#fragment%20encoded"
711        );
712    }
713
714    #[test]
715    fn unicode_in_state_dir() {
716        let mut opts = sample_runtime_options();
717        opts.state_dir = PathBuf::from("/tmp/工作目录/shipper-状态");
718        assert_eq!(
719            into_runtime_options(opts).state_dir,
720            PathBuf::from("/tmp/工作目录/shipper-状态")
721        );
722    }
723
724    #[test]
725    fn special_chars_in_registry_fields() {
726        let mut opts = sample_runtime_options();
727        opts.registries = vec![Registry {
728            name: "my-org/private-reg".to_string(),
729            api_base: "https://registry.example.com:8443/api/v1?token=abc&scope=all".to_string(),
730            index_base: Some("https://index.example.com/path with spaces/".to_string()),
731        }];
732        let converted = into_runtime_options(opts);
733        assert_eq!(converted.registries[0].name, "my-org/private-reg");
734        assert_eq!(
735            converted.registries[0].api_base,
736            "https://registry.example.com:8443/api/v1?token=abc&scope=all"
737        );
738        assert_eq!(
739            converted.registries[0].index_base.as_deref(),
740            Some("https://index.example.com/path with spaces/")
741        );
742    }
743
744    #[test]
745    fn special_chars_in_resume_from() {
746        let mut opts = sample_runtime_options();
747        opts.resume_from = Some("my-crate_v2.0.0-rc.1".to_string());
748        assert_eq!(
749            into_runtime_options(opts).resume_from.as_deref(),
750            Some("my-crate_v2.0.0-rc.1")
751        );
752    }
753
754    #[test]
755    fn special_chars_in_encryption_env_var() {
756        let mut opts = sample_runtime_options();
757        opts.encryption.env_var = Some("MY_APP__ENCRYPT_KEY_2".to_string());
758        assert_eq!(
759            into_runtime_options(opts).encryption.env_var.as_deref(),
760            Some("MY_APP__ENCRYPT_KEY_2")
761        );
762    }
763
764    #[test]
765    fn empty_string_webhook_url() {
766        let mut opts = sample_runtime_options();
767        opts.webhook.url = String::new();
768        assert_eq!(into_runtime_options(opts).webhook.url, "");
769    }
770
771    #[test]
772    fn special_chars_in_readiness_index_path() {
773        let mut opts = sample_runtime_options();
774        opts.readiness.index_path = Some(PathBuf::from("C:\\Users\\build agent\\index (2)"));
775        assert_eq!(
776            into_runtime_options(opts).readiness.index_path,
777            Some(PathBuf::from("C:\\Users\\build agent\\index (2)"))
778        );
779    }
780
781    // ── Multiple registries edge cases ─────────────────────────────────
782    #[test]
783    fn single_registry() {
784        let mut opts = sample_runtime_options();
785        opts.registries = vec![Registry {
786            name: "only".to_string(),
787            api_base: "https://only.example.com".to_string(),
788            index_base: None,
789        }];
790        let converted = into_runtime_options(opts);
791        assert_eq!(converted.registries.len(), 1);
792        assert_eq!(converted.registries[0].name, "only");
793    }
794
795    #[test]
796    fn many_registries() {
797        let mut opts = sample_runtime_options();
798        opts.registries = (0..20)
799            .map(|i| Registry {
800                name: format!("reg-{i}"),
801                api_base: format!("https://reg{i}.example.com"),
802                index_base: if i % 2 == 0 {
803                    Some(format!("https://index{i}.example.com"))
804                } else {
805                    None
806                },
807            })
808            .collect();
809        let converted = into_runtime_options(opts);
810        assert_eq!(converted.registries.len(), 20);
811        assert!(converted.registries[0].index_base.is_some());
812        assert!(converted.registries[1].index_base.is_none());
813    }
814
815    // ── Boundary values for numeric fields ─────────────────────────────
816    #[test]
817    fn max_attempts_one() {
818        let mut opts = sample_runtime_options();
819        opts.max_attempts = 1;
820        assert_eq!(into_runtime_options(opts).max_attempts, 1);
821    }
822
823    #[test]
824    fn max_attempts_u32_max() {
825        let mut opts = sample_runtime_options();
826        opts.max_attempts = u32::MAX;
827        assert_eq!(into_runtime_options(opts).max_attempts, u32::MAX);
828    }
829
830    #[test]
831    fn output_lines_max() {
832        let mut opts = sample_runtime_options();
833        opts.output_lines = usize::MAX;
834        assert_eq!(into_runtime_options(opts).output_lines, usize::MAX);
835    }
836
837    #[test]
838    fn parallel_max_concurrent_zero() {
839        let mut opts = sample_runtime_options();
840        opts.parallel.max_concurrent = 0;
841        assert_eq!(into_runtime_options(opts).parallel.max_concurrent, 0);
842    }
843
844    #[test]
845    fn retry_jitter_zero() {
846        let mut opts = sample_runtime_options();
847        opts.retry_jitter = 0.0;
848        let converted = into_runtime_options(opts);
849        assert!((converted.retry_jitter).abs() < f64::EPSILON);
850    }
851
852    #[test]
853    fn retry_jitter_one() {
854        let mut opts = sample_runtime_options();
855        opts.retry_jitter = 1.0;
856        let converted = into_runtime_options(opts);
857        assert!((converted.retry_jitter - 1.0).abs() < f64::EPSILON);
858    }
859
860    #[test]
861    fn webhook_timeout_zero() {
862        let mut opts = sample_runtime_options();
863        opts.webhook.timeout_secs = 0;
864        assert_eq!(into_runtime_options(opts).webhook.timeout_secs, 0);
865    }
866
867    mod snapshot_tests {
868        use super::*;
869        use insta::assert_debug_snapshot;
870
871        fn default_config_runtime() -> RuntimeOptions {
872            RuntimeOptions {
873                allow_dirty: false,
874                skip_ownership_check: false,
875                strict_ownership: false,
876                no_verify: false,
877                max_attempts: 3,
878                base_delay: Duration::from_secs(5),
879                max_delay: Duration::from_secs(300),
880                retry_strategy: shipper_retry::RetryStrategyType::Exponential,
881                retry_jitter: 0.25,
882                retry_per_error: shipper_retry::PerErrorConfig::default(),
883                verify_timeout: Duration::from_secs(60),
884                verify_poll_interval: Duration::from_secs(5),
885                state_dir: PathBuf::from(".shipper"),
886                force_resume: false,
887                policy: PublishPolicy::Safe,
888                verify_mode: VerifyMode::Workspace,
889                readiness: ReadinessConfig {
890                    enabled: false,
891                    method: ReadinessMethod::Api,
892                    initial_delay: Duration::from_millis(100),
893                    max_delay: Duration::from_secs(60),
894                    max_total_wait: Duration::from_secs(300),
895                    poll_interval: Duration::from_secs(5),
896                    jitter_factor: 0.25,
897                    prefer_index: false,
898                    index_path: None,
899                },
900                output_lines: 20,
901                force: false,
902                lock_timeout: Duration::from_secs(30),
903                parallel: ParallelConfig {
904                    enabled: false,
905                    max_concurrent: 4,
906                    per_package_timeout: Duration::from_secs(120),
907                },
908                webhook: WebhookConfig {
909                    url: String::new(),
910                    secret: None,
911                    ..WebhookConfig::default()
912                },
913                encryption: EncryptionConfig {
914                    enabled: false,
915                    passphrase: None,
916                    ..EncryptionConfig::default()
917                },
918                registries: vec![],
919                resume_from: None,
920                rehearsal_registry: None,
921                rehearsal_skip: false,
922                rehearsal_smoke_install: None,
923            }
924        }
925
926        #[test]
927        fn snapshot_default_conversion() {
928            let cfg = default_config_runtime();
929            let converted = into_runtime_options(cfg);
930            assert_debug_snapshot!(converted);
931        }
932
933        #[test]
934        fn snapshot_all_flags_enabled() {
935            let mut cfg = default_config_runtime();
936            cfg.allow_dirty = true;
937            cfg.skip_ownership_check = true;
938            cfg.strict_ownership = true;
939            cfg.no_verify = true;
940            cfg.force_resume = true;
941            cfg.force = true;
942            cfg.parallel.enabled = true;
943            cfg.readiness.enabled = true;
944            cfg.encryption.enabled = true;
945            let converted = into_runtime_options(cfg);
946            assert_debug_snapshot!(converted);
947        }
948
949        #[test]
950        fn snapshot_with_registries() {
951            let mut cfg = default_config_runtime();
952            cfg.registries = vec![
953                Registry {
954                    name: "crates-io".to_string(),
955                    api_base: "https://crates.io".to_string(),
956                    index_base: Some("https://index.crates.io".to_string()),
957                },
958                Registry {
959                    name: "private".to_string(),
960                    api_base: "https://my-registry.example.com".to_string(),
961                    index_base: None,
962                },
963            ];
964            let converted = into_runtime_options(cfg);
965            assert_debug_snapshot!(converted);
966        }
967
968        #[test]
969        fn snapshot_fast_policy_no_verify() {
970            let mut cfg = default_config_runtime();
971            cfg.policy = PublishPolicy::Fast;
972            cfg.verify_mode = VerifyMode::None;
973            cfg.no_verify = true;
974            cfg.max_attempts = 1;
975            cfg.base_delay = Duration::ZERO;
976            cfg.max_delay = Duration::ZERO;
977            let converted = into_runtime_options(cfg);
978            assert_debug_snapshot!(converted);
979        }
980
981        #[test]
982        fn snapshot_full_readiness_config() {
983            let mut cfg = default_config_runtime();
984            cfg.readiness = ReadinessConfig {
985                enabled: true,
986                method: ReadinessMethod::Both,
987                initial_delay: Duration::from_millis(500),
988                max_delay: Duration::from_secs(120),
989                max_total_wait: Duration::from_secs(600),
990                poll_interval: Duration::from_secs(10),
991                jitter_factor: 0.5,
992                prefer_index: true,
993                index_path: Some(PathBuf::from("/custom/index")),
994            };
995            let converted = into_runtime_options(cfg);
996            assert_debug_snapshot!(converted);
997        }
998
999        #[test]
1000        fn snapshot_parallel_heavy() {
1001            let mut cfg = default_config_runtime();
1002            cfg.parallel = ParallelConfig {
1003                enabled: true,
1004                max_concurrent: 16,
1005                per_package_timeout: Duration::from_secs(3600),
1006            };
1007            cfg.lock_timeout = Duration::from_secs(7200);
1008            let converted = into_runtime_options(cfg);
1009            assert_debug_snapshot!(converted);
1010        }
1011
1012        #[test]
1013        fn snapshot_webhook_with_secret() {
1014            let mut cfg = default_config_runtime();
1015            cfg.webhook = WebhookConfig {
1016                url: "https://hooks.slack.com/services/T00/B00/xxxx".to_string(),
1017                secret: Some("hmac-secret-key".to_string()),
1018                timeout_secs: 5,
1019                ..WebhookConfig::default()
1020            };
1021            let converted = into_runtime_options(cfg);
1022            assert_debug_snapshot!(converted);
1023        }
1024
1025        #[test]
1026        fn snapshot_encryption_with_env_var() {
1027            let mut cfg = default_config_runtime();
1028            cfg.encryption = EncryptionConfig {
1029                enabled: true,
1030                passphrase: None,
1031                env_var: Some("CI_ENCRYPT_KEY".to_string()),
1032            };
1033            let converted = into_runtime_options(cfg);
1034            assert_debug_snapshot!(converted);
1035        }
1036
1037        #[test]
1038        fn snapshot_linear_retry_strategy() {
1039            let mut cfg = default_config_runtime();
1040            cfg.retry_strategy = shipper_retry::RetryStrategyType::Linear;
1041            cfg.retry_jitter = 0.0;
1042            cfg.max_attempts = 10;
1043            cfg.base_delay = Duration::from_millis(100);
1044            cfg.max_delay = Duration::from_secs(10);
1045            let converted = into_runtime_options(cfg);
1046            assert_debug_snapshot!(converted);
1047        }
1048
1049        #[test]
1050        fn snapshot_resume_from_set() {
1051            let mut cfg = default_config_runtime();
1052            cfg.resume_from = Some("my-sub-crate".to_string());
1053            cfg.force_resume = true;
1054            let converted = into_runtime_options(cfg);
1055            assert_debug_snapshot!(converted);
1056        }
1057
1058        #[test]
1059        fn snapshot_balanced_policy_with_partial_config() {
1060            let mut cfg = default_config_runtime();
1061            cfg.policy = PublishPolicy::Balanced;
1062            cfg.max_attempts = 5;
1063            cfg.parallel.enabled = true;
1064            cfg.parallel.max_concurrent = 2;
1065            cfg.readiness.enabled = true;
1066            cfg.readiness.method = ReadinessMethod::Api;
1067            let converted = into_runtime_options(cfg);
1068            assert_debug_snapshot!(converted);
1069        }
1070
1071        #[test]
1072        fn snapshot_safe_policy_max_safety() {
1073            let mut cfg = default_config_runtime();
1074            cfg.policy = PublishPolicy::Safe;
1075            cfg.verify_mode = VerifyMode::Workspace;
1076            cfg.readiness = ReadinessConfig {
1077                enabled: true,
1078                method: ReadinessMethod::Both,
1079                initial_delay: Duration::from_secs(1),
1080                max_delay: Duration::from_secs(120),
1081                max_total_wait: Duration::from_secs(600),
1082                poll_interval: Duration::from_secs(5),
1083                jitter_factor: 0.5,
1084                prefer_index: true,
1085                index_path: Some(PathBuf::from("/ci/index")),
1086            };
1087            cfg.max_attempts = 10;
1088            cfg.retry_strategy = shipper_retry::RetryStrategyType::Exponential;
1089            cfg.retry_jitter = 0.5;
1090            let converted = into_runtime_options(cfg);
1091            assert_debug_snapshot!(converted);
1092        }
1093
1094        #[test]
1095        fn snapshot_alternative_registry_only() {
1096            let mut cfg = default_config_runtime();
1097            cfg.registries = vec![Registry {
1098                name: "my-private-registry".to_string(),
1099                api_base: "https://registry.internal.corp:8443".to_string(),
1100                index_base: Some("https://index.internal.corp:8443".to_string()),
1101            }];
1102            let converted = into_runtime_options(cfg);
1103            assert_debug_snapshot!(converted);
1104        }
1105    }
1106
1107    // ── Flag precedence tests ──────────────────────────────────────────
1108    mod flag_precedence {
1109        use super::*;
1110
1111        #[test]
1112        fn cli_true_overrides_source_false_for_allow_dirty() {
1113            let mut opts = sample_runtime_options();
1114            opts.allow_dirty = false;
1115            let converted = into_runtime_options(opts);
1116            assert!(!converted.allow_dirty);
1117
1118            let mut opts2 = sample_runtime_options();
1119            opts2.allow_dirty = true;
1120            let converted2 = into_runtime_options(opts2);
1121            assert!(converted2.allow_dirty);
1122        }
1123
1124        #[test]
1125        fn policy_field_is_faithfully_forwarded() {
1126            for policy in [
1127                PublishPolicy::Safe,
1128                PublishPolicy::Balanced,
1129                PublishPolicy::Fast,
1130            ] {
1131                let mut opts = sample_runtime_options();
1132                opts.policy = policy;
1133                let converted = into_runtime_options(opts);
1134                assert_eq!(converted.policy, policy, "policy mismatch for {policy:?}");
1135            }
1136        }
1137
1138        #[test]
1139        fn verify_mode_field_is_faithfully_forwarded() {
1140            for mode in [VerifyMode::Workspace, VerifyMode::Package, VerifyMode::None] {
1141                let mut opts = sample_runtime_options();
1142                opts.verify_mode = mode;
1143                let converted = into_runtime_options(opts);
1144                assert_eq!(
1145                    converted.verify_mode, mode,
1146                    "verify_mode mismatch for {mode:?}"
1147                );
1148            }
1149        }
1150
1151        #[test]
1152        fn all_boolean_flags_independently_toggled() {
1153            // Test each boolean flag can be true while others are false.
1154            // Each tuple: (index to set true, readable label).
1155            let flag_indices = [0, 1, 2, 3, 4, 5];
1156
1157            for idx in flag_indices {
1158                let mut opts = sample_runtime_options();
1159                opts.allow_dirty = false;
1160                opts.skip_ownership_check = false;
1161                opts.strict_ownership = false;
1162                opts.no_verify = false;
1163                opts.force = false;
1164                opts.force_resume = false;
1165                match idx {
1166                    0 => opts.allow_dirty = true,
1167                    1 => opts.skip_ownership_check = true,
1168                    2 => opts.strict_ownership = true,
1169                    3 => opts.no_verify = true,
1170                    4 => opts.force = true,
1171                    5 => opts.force_resume = true,
1172                    _ => unreachable!(),
1173                }
1174                let converted = into_runtime_options(opts);
1175                let count = [
1176                    converted.allow_dirty,
1177                    converted.skip_ownership_check,
1178                    converted.strict_ownership,
1179                    converted.no_verify,
1180                    converted.force,
1181                    converted.force_resume,
1182                ]
1183                .iter()
1184                .filter(|&&v| v)
1185                .count();
1186                assert_eq!(
1187                    count, 1,
1188                    "exactly one boolean flag should be true at idx {idx}"
1189                );
1190            }
1191        }
1192
1193        #[test]
1194        fn retry_strategy_variants_all_pass_through() {
1195            for strategy in [
1196                shipper_retry::RetryStrategyType::Immediate,
1197                shipper_retry::RetryStrategyType::Exponential,
1198                shipper_retry::RetryStrategyType::Linear,
1199                shipper_retry::RetryStrategyType::Constant,
1200            ] {
1201                let mut opts = sample_runtime_options();
1202                opts.retry_strategy = strategy;
1203                assert_eq!(into_runtime_options(opts).retry_strategy, strategy);
1204            }
1205        }
1206    }
1207
1208    // ── Default values tests ───────────────────────────────────────────
1209    mod default_value_tests {
1210        use super::*;
1211
1212        fn minimal_defaults() -> RuntimeOptions {
1213            RuntimeOptions {
1214                allow_dirty: false,
1215                skip_ownership_check: false,
1216                strict_ownership: false,
1217                no_verify: false,
1218                max_attempts: 3,
1219                base_delay: Duration::from_secs(1),
1220                max_delay: Duration::from_secs(60),
1221                retry_strategy: shipper_retry::RetryStrategyType::Exponential,
1222                retry_jitter: 0.25,
1223                retry_per_error: shipper_retry::PerErrorConfig::default(),
1224                verify_timeout: Duration::from_secs(60),
1225                verify_poll_interval: Duration::from_secs(5),
1226                state_dir: PathBuf::from(".shipper"),
1227                force_resume: false,
1228                policy: PublishPolicy::Safe,
1229                verify_mode: VerifyMode::Workspace,
1230                readiness: ReadinessConfig::default(),
1231                output_lines: 50,
1232                force: false,
1233                lock_timeout: Duration::from_secs(3600),
1234                parallel: ParallelConfig::default(),
1235                webhook: WebhookConfig::default(),
1236                encryption: EncryptionConfig::default(),
1237                registries: vec![],
1238                resume_from: None,
1239                rehearsal_registry: None,
1240                rehearsal_skip: false,
1241                rehearsal_smoke_install: None,
1242            }
1243        }
1244
1245        #[test]
1246        fn defaults_no_config_no_flags_all_booleans_false() {
1247            let converted = into_runtime_options(minimal_defaults());
1248            assert!(!converted.allow_dirty);
1249            assert!(!converted.skip_ownership_check);
1250            assert!(!converted.strict_ownership);
1251            assert!(!converted.no_verify);
1252            assert!(!converted.force);
1253            assert!(!converted.force_resume);
1254        }
1255
1256        #[test]
1257        fn defaults_policy_is_safe() {
1258            let converted = into_runtime_options(minimal_defaults());
1259            assert_eq!(converted.policy, expected_types::PublishPolicy::Safe);
1260        }
1261
1262        #[test]
1263        fn defaults_verify_mode_is_workspace() {
1264            let converted = into_runtime_options(minimal_defaults());
1265            assert_eq!(converted.verify_mode, expected_types::VerifyMode::Workspace);
1266        }
1267
1268        #[test]
1269        fn defaults_registries_empty() {
1270            let converted = into_runtime_options(minimal_defaults());
1271            assert!(converted.registries.is_empty());
1272        }
1273
1274        #[test]
1275        fn defaults_resume_from_none() {
1276            let converted = into_runtime_options(minimal_defaults());
1277            assert!(converted.resume_from.is_none());
1278        }
1279
1280        #[test]
1281        fn defaults_parallel_disabled() {
1282            let converted = into_runtime_options(minimal_defaults());
1283            assert!(!converted.parallel.enabled);
1284        }
1285
1286        #[test]
1287        fn defaults_encryption_disabled() {
1288            let converted = into_runtime_options(minimal_defaults());
1289            assert!(!converted.encryption.enabled);
1290            assert!(converted.encryption.passphrase.is_none());
1291            assert!(converted.encryption.env_var.is_none());
1292        }
1293
1294        #[test]
1295        fn defaults_webhook_empty() {
1296            let converted = into_runtime_options(minimal_defaults());
1297            assert!(converted.webhook.url.is_empty());
1298            assert!(converted.webhook.secret.is_none());
1299        }
1300    }
1301
1302    // ── Partial config tests ───────────────────────────────────────────
1303    mod partial_config_tests {
1304        use super::*;
1305
1306        #[test]
1307        fn partial_only_policy_set() {
1308            let mut opts = sample_runtime_options();
1309            opts.policy = PublishPolicy::Fast;
1310            // Leave everything else as sample defaults
1311            let converted = into_runtime_options(opts);
1312            assert_eq!(converted.policy, expected_types::PublishPolicy::Fast);
1313            // Other fields should still be from sample_runtime_options
1314            assert!(converted.allow_dirty);
1315            assert_eq!(converted.max_attempts, 8);
1316        }
1317
1318        #[test]
1319        fn partial_only_readiness_set() {
1320            let mut opts = sample_runtime_options();
1321            opts.readiness = ReadinessConfig {
1322                enabled: true,
1323                method: ReadinessMethod::Index,
1324                initial_delay: Duration::from_millis(200),
1325                max_delay: Duration::from_secs(15),
1326                max_total_wait: Duration::from_secs(120),
1327                poll_interval: Duration::from_secs(2),
1328                jitter_factor: 0.1,
1329                index_path: None,
1330                prefer_index: true,
1331            };
1332            let converted = into_runtime_options(opts);
1333            assert!(converted.readiness.enabled);
1334            assert_eq!(
1335                converted.readiness.method,
1336                expected_types::ReadinessMethod::Index
1337            );
1338            assert!(converted.readiness.prefer_index);
1339            assert!(converted.readiness.index_path.is_none());
1340            // Non-readiness fields untouched
1341            assert_eq!(converted.policy, expected_types::PublishPolicy::Balanced);
1342        }
1343
1344        #[test]
1345        fn partial_only_parallel_set() {
1346            let mut opts = sample_runtime_options();
1347            opts.parallel = ParallelConfig {
1348                enabled: true,
1349                max_concurrent: 12,
1350                per_package_timeout: Duration::from_secs(600),
1351            };
1352            let converted = into_runtime_options(opts);
1353            assert!(converted.parallel.enabled);
1354            assert_eq!(converted.parallel.max_concurrent, 12);
1355            assert_eq!(
1356                converted.parallel.per_package_timeout,
1357                Duration::from_secs(600)
1358            );
1359        }
1360
1361        #[test]
1362        fn partial_only_encryption_set() {
1363            let mut opts = sample_runtime_options();
1364            opts.encryption = EncryptionConfig {
1365                enabled: true,
1366                passphrase: Some("partial-pass".to_string()),
1367                env_var: None,
1368            };
1369            let converted = into_runtime_options(opts);
1370            assert!(converted.encryption.enabled);
1371            assert_eq!(
1372                converted.encryption.passphrase.as_deref(),
1373                Some("partial-pass")
1374            );
1375            assert!(converted.encryption.env_var.is_none());
1376        }
1377
1378        #[test]
1379        fn partial_only_webhook_set() {
1380            let mut opts = sample_runtime_options();
1381            opts.webhook = WebhookConfig {
1382                url: "https://partial.example/hook".to_string(),
1383                secret: None,
1384                timeout_secs: 10,
1385                ..WebhookConfig::default()
1386            };
1387            let converted = into_runtime_options(opts);
1388            assert_eq!(converted.webhook.url, "https://partial.example/hook");
1389            assert!(converted.webhook.secret.is_none());
1390            assert_eq!(converted.webhook.timeout_secs, 10);
1391        }
1392    }
1393
1394    // ── Policy combination tests ───────────────────────────────────────
1395    mod policy_combination_tests {
1396        use super::*;
1397        use insta::assert_debug_snapshot;
1398
1399        fn opts_with_policy(policy: PublishPolicy) -> RuntimeOptions {
1400            let mut opts = RuntimeOptions {
1401                allow_dirty: false,
1402                skip_ownership_check: false,
1403                strict_ownership: false,
1404                no_verify: false,
1405                max_attempts: 5,
1406                base_delay: Duration::from_secs(2),
1407                max_delay: Duration::from_secs(60),
1408                retry_strategy: shipper_retry::RetryStrategyType::Exponential,
1409                retry_jitter: 0.25,
1410                retry_per_error: shipper_retry::PerErrorConfig::default(),
1411                verify_timeout: Duration::from_secs(120),
1412                verify_poll_interval: Duration::from_secs(5),
1413                state_dir: PathBuf::from(".shipper"),
1414                force_resume: false,
1415                policy,
1416                verify_mode: VerifyMode::Workspace,
1417                readiness: ReadinessConfig::default(),
1418                output_lines: 50,
1419                force: false,
1420                lock_timeout: Duration::from_secs(3600),
1421                parallel: ParallelConfig::default(),
1422                webhook: WebhookConfig::default(),
1423                encryption: EncryptionConfig::default(),
1424                registries: vec![],
1425                resume_from: None,
1426                rehearsal_registry: None,
1427                rehearsal_skip: false,
1428                rehearsal_smoke_install: None,
1429            };
1430            // Adjust verify_mode to match typical policy usage
1431            match policy {
1432                PublishPolicy::Safe => {
1433                    opts.verify_mode = VerifyMode::Workspace;
1434                    opts.readiness.enabled = true;
1435                }
1436                PublishPolicy::Balanced => {
1437                    opts.verify_mode = VerifyMode::Package;
1438                    opts.readiness.enabled = true;
1439                }
1440                PublishPolicy::Fast => {
1441                    opts.verify_mode = VerifyMode::None;
1442                    opts.no_verify = true;
1443                    opts.readiness.enabled = false;
1444                }
1445            }
1446            opts
1447        }
1448
1449        #[test]
1450        fn safe_policy_produces_correct_options() {
1451            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Safe));
1452            assert_eq!(converted.policy, expected_types::PublishPolicy::Safe);
1453            assert_eq!(converted.verify_mode, expected_types::VerifyMode::Workspace);
1454            assert!(converted.readiness.enabled);
1455            assert!(!converted.no_verify);
1456        }
1457
1458        #[test]
1459        fn balanced_policy_produces_correct_options() {
1460            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Balanced));
1461            assert_eq!(converted.policy, expected_types::PublishPolicy::Balanced);
1462            assert_eq!(converted.verify_mode, expected_types::VerifyMode::Package);
1463            assert!(converted.readiness.enabled);
1464            assert!(!converted.no_verify);
1465        }
1466
1467        #[test]
1468        fn fast_policy_produces_correct_options() {
1469            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Fast));
1470            assert_eq!(converted.policy, expected_types::PublishPolicy::Fast);
1471            assert_eq!(converted.verify_mode, expected_types::VerifyMode::None);
1472            assert!(!converted.readiness.enabled);
1473            assert!(converted.no_verify);
1474        }
1475
1476        #[test]
1477        fn snapshot_safe_policy_typical() {
1478            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Safe));
1479            assert_debug_snapshot!(converted);
1480        }
1481
1482        #[test]
1483        fn snapshot_balanced_policy_typical() {
1484            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Balanced));
1485            assert_debug_snapshot!(converted);
1486        }
1487
1488        #[test]
1489        fn snapshot_fast_policy_typical() {
1490            let converted = into_runtime_options(opts_with_policy(PublishPolicy::Fast));
1491            assert_debug_snapshot!(converted);
1492        }
1493    }
1494
1495    // ── Registry configuration tests ───────────────────────────────────
1496    mod registry_tests {
1497        use super::*;
1498
1499        #[test]
1500        fn multiple_alternative_registries_preserved() {
1501            let mut opts = sample_runtime_options();
1502            opts.registries = vec![
1503                Registry {
1504                    name: "crates-io".to_string(),
1505                    api_base: "https://crates.io".to_string(),
1506                    index_base: Some("https://index.crates.io".to_string()),
1507                },
1508                Registry {
1509                    name: "private-npm".to_string(),
1510                    api_base: "https://npm.internal.corp".to_string(),
1511                    index_base: None,
1512                },
1513                Registry {
1514                    name: "staging".to_string(),
1515                    api_base: "https://staging.registry.example.com".to_string(),
1516                    index_base: Some("https://staging-index.registry.example.com".to_string()),
1517                },
1518            ];
1519            let converted = into_runtime_options(opts);
1520            assert_eq!(converted.registries.len(), 3);
1521            assert_eq!(converted.registries[0].name, "crates-io");
1522            assert_eq!(converted.registries[1].name, "private-npm");
1523            assert!(converted.registries[1].index_base.is_none());
1524            assert_eq!(converted.registries[2].name, "staging");
1525            assert!(converted.registries[2].index_base.is_some());
1526        }
1527
1528        #[test]
1529        fn registry_with_port_and_path() {
1530            let mut opts = sample_runtime_options();
1531            opts.registries = vec![Registry {
1532                name: "local-dev".to_string(),
1533                api_base: "http://localhost:8080/api/v1".to_string(),
1534                index_base: Some("http://localhost:8080/index".to_string()),
1535            }];
1536            let converted = into_runtime_options(opts);
1537            assert_eq!(
1538                converted.registries[0].api_base,
1539                "http://localhost:8080/api/v1"
1540            );
1541            assert_eq!(
1542                converted.registries[0].index_base.as_deref(),
1543                Some("http://localhost:8080/index")
1544            );
1545        }
1546
1547        #[test]
1548        fn registry_order_is_preserved() {
1549            let names: Vec<String> = (0..10).map(|i| format!("reg-{i}")).collect();
1550            let mut opts = sample_runtime_options();
1551            opts.registries = names
1552                .iter()
1553                .map(|n| Registry {
1554                    name: n.clone(),
1555                    api_base: format!("https://{n}.example.com"),
1556                    index_base: None,
1557                })
1558                .collect();
1559            let converted = into_runtime_options(opts);
1560            let converted_names: Vec<&str> = converted
1561                .registries
1562                .iter()
1563                .map(|r| r.name.as_str())
1564                .collect();
1565            let expected_names: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
1566            assert_eq!(converted_names, expected_names);
1567        }
1568
1569        #[test]
1570        fn registry_with_all_fields_populated() {
1571            let mut opts = sample_runtime_options();
1572            opts.registries = vec![Registry {
1573                name: "full-config".to_string(),
1574                api_base: "https://full.example.com/api".to_string(),
1575                index_base: Some("https://full.example.com/index".to_string()),
1576            }];
1577            let converted = into_runtime_options(opts);
1578            assert_eq!(converted.registries.len(), 1);
1579            let r = &converted.registries[0];
1580            assert_eq!(r.name, "full-config");
1581            assert_eq!(r.api_base, "https://full.example.com/api");
1582            assert_eq!(
1583                r.index_base.as_deref(),
1584                Some("https://full.example.com/index")
1585            );
1586        }
1587    }
1588
1589    // ── Additional proptest ────────────────────────────────────────────
1590    mod proptest_hardened {
1591        use super::*;
1592
1593        proptest! {
1594            #[test]
1595            fn arbitrary_durations_survive_conversion(
1596                base_ms in 0u64..100_000,
1597                max_ms in 0u64..100_000,
1598                verify_ms in 0u64..100_000,
1599                poll_ms in 0u64..100_000,
1600                lock_ms in 0u64..100_000,
1601                pkg_timeout_ms in 0u64..100_000,
1602            ) {
1603                let mut opts = sample_runtime_options();
1604                opts.base_delay = Duration::from_millis(base_ms);
1605                opts.max_delay = Duration::from_millis(max_ms);
1606                opts.verify_timeout = Duration::from_millis(verify_ms);
1607                opts.verify_poll_interval = Duration::from_millis(poll_ms);
1608                opts.lock_timeout = Duration::from_millis(lock_ms);
1609                opts.parallel.per_package_timeout = Duration::from_millis(pkg_timeout_ms);
1610
1611                let converted = into_runtime_options(opts);
1612
1613                prop_assert_eq!(converted.base_delay, Duration::from_millis(base_ms));
1614                prop_assert_eq!(converted.max_delay, Duration::from_millis(max_ms));
1615                prop_assert_eq!(converted.verify_timeout, Duration::from_millis(verify_ms));
1616                prop_assert_eq!(converted.verify_poll_interval, Duration::from_millis(poll_ms));
1617                prop_assert_eq!(converted.lock_timeout, Duration::from_millis(lock_ms));
1618                prop_assert_eq!(
1619                    converted.parallel.per_package_timeout,
1620                    Duration::from_millis(pkg_timeout_ms)
1621                );
1622            }
1623
1624            #[test]
1625            fn arbitrary_string_fields_survive_conversion(
1626                webhook_url in "\\PC{0,64}",
1627                secret in proptest::option::of("\\PC{0,32}"),
1628                passphrase in proptest::option::of("\\PC{0,32}"),
1629                env_var in proptest::option::of("[A-Z_]{1,32}"),
1630                resume in proptest::option::of("[a-z0-9_-]{1,32}"),
1631                reg_count in 0usize..5,
1632            ) {
1633                let mut opts = sample_runtime_options();
1634                opts.webhook.url = webhook_url.clone();
1635                opts.webhook.secret = secret.clone();
1636                opts.encryption.passphrase = passphrase.clone();
1637                opts.encryption.env_var = env_var.clone();
1638                opts.resume_from = resume.clone();
1639                opts.registries = (0..reg_count)
1640                    .map(|i| Registry {
1641                        name: format!("r-{i}"),
1642                        api_base: format!("https://r{i}.example"),
1643                        index_base: None,
1644                    })
1645                    .collect();
1646
1647                let converted = into_runtime_options(opts);
1648
1649                prop_assert_eq!(&converted.webhook.url, &webhook_url);
1650                prop_assert_eq!(&converted.webhook.secret, &secret);
1651                prop_assert_eq!(&converted.encryption.passphrase, &passphrase);
1652                prop_assert_eq!(&converted.encryption.env_var, &env_var);
1653                prop_assert_eq!(&converted.resume_from, &resume);
1654                prop_assert_eq!(converted.registries.len(), reg_count);
1655            }
1656        }
1657    }
1658
1659    // ── Composite / integration-style tests ────────────────────────────
1660    mod composite_tests {
1661        use super::*;
1662
1663        #[test]
1664        fn full_config_all_fields_populated_roundtrips() {
1665            let original = sample_runtime_options();
1666            let converted = into_runtime_options(original.clone());
1667
1668            assert_eq!(converted.allow_dirty, original.allow_dirty);
1669            assert_eq!(
1670                converted.skip_ownership_check,
1671                original.skip_ownership_check
1672            );
1673            assert_eq!(converted.strict_ownership, original.strict_ownership);
1674            assert_eq!(converted.no_verify, original.no_verify);
1675            assert_eq!(converted.max_attempts, original.max_attempts);
1676            assert_eq!(converted.base_delay, original.base_delay);
1677            assert_eq!(converted.max_delay, original.max_delay);
1678            assert_eq!(converted.retry_strategy, original.retry_strategy);
1679            assert!((converted.retry_jitter - original.retry_jitter).abs() < f64::EPSILON);
1680            assert_eq!(converted.verify_timeout, original.verify_timeout);
1681            assert_eq!(
1682                converted.verify_poll_interval,
1683                original.verify_poll_interval
1684            );
1685            assert_eq!(converted.state_dir, original.state_dir);
1686            assert_eq!(converted.force_resume, original.force_resume);
1687            assert_eq!(converted.policy, original.policy);
1688            assert_eq!(converted.verify_mode, original.verify_mode);
1689            assert_eq!(converted.readiness.enabled, original.readiness.enabled);
1690            assert_eq!(converted.readiness.method, original.readiness.method);
1691            assert_eq!(
1692                converted.readiness.initial_delay,
1693                original.readiness.initial_delay
1694            );
1695            assert_eq!(converted.readiness.max_delay, original.readiness.max_delay);
1696            assert_eq!(
1697                converted.readiness.max_total_wait,
1698                original.readiness.max_total_wait
1699            );
1700            assert_eq!(
1701                converted.readiness.poll_interval,
1702                original.readiness.poll_interval
1703            );
1704            assert!(
1705                (converted.readiness.jitter_factor - original.readiness.jitter_factor).abs()
1706                    < f64::EPSILON
1707            );
1708            assert_eq!(
1709                converted.readiness.index_path,
1710                original.readiness.index_path
1711            );
1712            assert_eq!(
1713                converted.readiness.prefer_index,
1714                original.readiness.prefer_index
1715            );
1716            assert_eq!(converted.output_lines, original.output_lines);
1717            assert_eq!(converted.force, original.force);
1718            assert_eq!(converted.lock_timeout, original.lock_timeout);
1719            assert_eq!(converted.parallel.enabled, original.parallel.enabled);
1720            assert_eq!(
1721                converted.parallel.max_concurrent,
1722                original.parallel.max_concurrent
1723            );
1724            assert_eq!(
1725                converted.parallel.per_package_timeout,
1726                original.parallel.per_package_timeout
1727            );
1728            assert_eq!(converted.webhook.url, original.webhook.url);
1729            assert_eq!(converted.webhook.secret, original.webhook.secret);
1730            assert_eq!(
1731                converted.webhook.timeout_secs,
1732                original.webhook.timeout_secs
1733            );
1734            assert_eq!(converted.encryption.enabled, original.encryption.enabled);
1735            assert_eq!(
1736                converted.encryption.passphrase,
1737                original.encryption.passphrase
1738            );
1739            assert_eq!(converted.encryption.env_var, original.encryption.env_var);
1740            assert_eq!(converted.registries.len(), original.registries.len());
1741            for (c, o) in converted.registries.iter().zip(original.registries.iter()) {
1742                assert_eq!(c.name, o.name);
1743                assert_eq!(c.api_base, o.api_base);
1744                assert_eq!(c.index_base, o.index_base);
1745            }
1746            assert_eq!(converted.resume_from, original.resume_from);
1747        }
1748
1749        #[test]
1750        fn extreme_values_combined() {
1751            let opts = RuntimeOptions {
1752                allow_dirty: true,
1753                skip_ownership_check: true,
1754                strict_ownership: true,
1755                no_verify: true,
1756                max_attempts: u32::MAX,
1757                base_delay: Duration::ZERO,
1758                max_delay: Duration::from_secs(u64::MAX / 2),
1759                retry_strategy: shipper_retry::RetryStrategyType::Immediate,
1760                retry_jitter: 0.0,
1761                retry_per_error: shipper_retry::PerErrorConfig::default(),
1762                verify_timeout: Duration::ZERO,
1763                verify_poll_interval: Duration::from_nanos(1),
1764                state_dir: PathBuf::from(""),
1765                force_resume: true,
1766                policy: PublishPolicy::Fast,
1767                verify_mode: VerifyMode::None,
1768                readiness: ReadinessConfig {
1769                    enabled: false,
1770                    method: ReadinessMethod::Api,
1771                    initial_delay: Duration::ZERO,
1772                    max_delay: Duration::ZERO,
1773                    max_total_wait: Duration::ZERO,
1774                    poll_interval: Duration::ZERO,
1775                    jitter_factor: 0.0,
1776                    index_path: None,
1777                    prefer_index: false,
1778                },
1779                output_lines: 0,
1780                force: true,
1781                lock_timeout: Duration::ZERO,
1782                parallel: ParallelConfig {
1783                    enabled: false,
1784                    max_concurrent: 0,
1785                    per_package_timeout: Duration::ZERO,
1786                },
1787                webhook: WebhookConfig::default(),
1788                encryption: EncryptionConfig::default(),
1789                registries: vec![],
1790                resume_from: Some(String::new()),
1791                rehearsal_registry: None,
1792                rehearsal_skip: false,
1793                rehearsal_smoke_install: None,
1794            };
1795
1796            let converted = into_runtime_options(opts);
1797            assert_eq!(converted.max_attempts, u32::MAX);
1798            assert_eq!(converted.base_delay, Duration::ZERO);
1799            assert_eq!(converted.output_lines, 0);
1800            assert_eq!(converted.state_dir, PathBuf::from(""));
1801            assert_eq!(converted.resume_from.as_deref(), Some(""));
1802        }
1803    }
1804}