1use std::path::{Path, PathBuf};
35use std::time::Duration;
36
37use anyhow::{Context, Result, bail};
38use serde::{Deserialize, Serialize};
39use serde_with::serde_as;
40
41pub use shipper_encrypt::EncryptionConfig;
42pub use shipper_types::{
43 ParallelConfig, PublishPolicy, ReadinessConfig, ReadinessMethod, Registry, RuntimeOptions,
44 VerifyMode, deserialize_duration, serialize_duration,
45};
46pub use shipper_webhook::WebhookConfig;
47
48use shipper_retry::{PerErrorConfig, RetryPolicy, RetryStrategyType};
49use shipper_types::storage::{CloudStorageConfig, StorageType};
50
51pub mod runtime;
53
54mod runtime_options;
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct PolicyConfig {
59 #[serde(default)]
61 pub mode: PublishPolicy,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct VerifyConfig {
67 #[serde(default)]
69 pub mode: VerifyMode,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RetryConfig {
75 #[serde(default)]
77 pub policy: RetryPolicy,
78
79 #[serde(default = "default_max_attempts")]
81 pub max_attempts: u32,
82
83 #[serde(
85 deserialize_with = "deserialize_duration",
86 serialize_with = "serialize_duration"
87 )]
88 #[serde(default = "default_base_delay")]
89 pub base_delay: Duration,
90
91 #[serde(
93 deserialize_with = "deserialize_duration",
94 serialize_with = "serialize_duration"
95 )]
96 #[serde(default = "default_max_delay")]
97 pub max_delay: Duration,
98
99 #[serde(default)]
101 pub strategy: RetryStrategyType,
102
103 #[serde(default = "default_jitter")]
105 pub jitter: f64,
106
107 #[serde(default)]
109 pub per_error: PerErrorConfig,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct OutputConfig {
115 #[serde(default = "default_output_lines")]
117 pub lines: usize,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct LockConfig {
123 #[serde(
125 deserialize_with = "deserialize_duration",
126 serialize_with = "serialize_duration"
127 )]
128 #[serde(default = "default_lock_timeout")]
129 pub timeout: Duration,
130}
131
132impl Default for RetryConfig {
133 fn default() -> Self {
134 Self {
135 policy: RetryPolicy::Default,
136 max_attempts: default_max_attempts(),
137 base_delay: default_base_delay(),
138 max_delay: default_max_delay(),
139 strategy: RetryStrategyType::Exponential,
140 jitter: 0.5,
141 per_error: PerErrorConfig::default(),
142 }
143 }
144}
145
146fn default_jitter() -> f64 {
147 0.5
148}
149
150impl Default for OutputConfig {
151 fn default() -> Self {
152 Self {
153 lines: default_output_lines(),
154 }
155 }
156}
157
158impl Default for LockConfig {
159 fn default() -> Self {
160 Self {
161 timeout: default_lock_timeout(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub struct EncryptionConfigInner {
169 #[serde(default)]
171 pub enabled: bool,
172 #[serde(default)]
174 pub passphrase: Option<String>,
175 #[serde(default)]
177 pub env_key: Option<String>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct StorageConfigInner {
183 #[serde(default)]
185 pub storage_type: StorageType,
186 #[serde(default)]
188 pub bucket: Option<String>,
189 #[serde(default)]
191 pub region: Option<String>,
192 #[serde(default)]
194 pub base_path: Option<String>,
195 #[serde(default)]
197 pub endpoint: Option<String>,
198 #[serde(default)]
200 pub access_key_id: Option<String>,
201 #[serde(default)]
203 pub secret_access_key: Option<String>,
204}
205
206impl StorageConfigInner {
207 pub fn to_cloud_config(&self) -> Option<CloudStorageConfig> {
211 let bucket = self.bucket.as_ref()?;
213
214 let mut config = CloudStorageConfig::new(self.storage_type, bucket.clone());
215
216 if let Some(ref region) = self.region {
217 config.region = Some(region.clone());
218 }
219 if let Some(ref base_path) = self.base_path {
220 config.base_path = base_path.clone();
221 }
222 if let Some(ref endpoint) = self.endpoint {
223 config.endpoint = Some(endpoint.clone());
224 }
225 if let Some(ref access_key_id) = self.access_key_id {
226 config.access_key_id = Some(access_key_id.clone());
227 }
228 if let Some(ref secret_access_key) = self.secret_access_key {
229 config.secret_access_key = Some(secret_access_key.clone());
230 }
231
232 config.access_key_id = config
234 .access_key_id
235 .clone()
236 .or_else(|| std::env::var("SHIPPER_STORAGE_ACCESS_KEY_ID").ok());
237 config.secret_access_key = config
238 .secret_access_key
239 .clone()
240 .or_else(|| std::env::var("SHIPPER_STORAGE_SECRET_ACCESS_KEY").ok());
241 config.region = config
242 .region
243 .clone()
244 .or_else(|| std::env::var("SHIPPER_STORAGE_REGION").ok());
245
246 Some(config)
247 }
248
249 pub fn is_configured(&self) -> bool {
251 self.bucket.is_some() && self.storage_type != StorageType::File
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
257pub struct FlagsConfig {
258 #[serde(default)]
260 pub allow_dirty: bool,
261
262 #[serde(default)]
264 pub skip_ownership_check: bool,
265
266 #[serde(default)]
268 pub strict_ownership: bool,
269}
270
271#[serde_as]
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ShipperConfig {
282 #[serde(default = "default_schema_version")]
284 pub schema_version: String,
285
286 #[serde(default)]
288 pub policy: PolicyConfig,
289
290 #[serde(default)]
292 pub verify: VerifyConfig,
293
294 #[serde(default)]
296 pub readiness: ReadinessConfig,
297
298 #[serde(default)]
300 pub output: OutputConfig,
301
302 #[serde(default)]
304 pub lock: LockConfig,
305
306 #[serde(default)]
308 pub retry: RetryConfig,
309
310 #[serde(default)]
312 pub flags: FlagsConfig,
313
314 #[serde(default)]
316 pub parallel: ParallelConfig,
317
318 #[serde(default)]
320 pub state_dir: Option<PathBuf>,
321
322 #[serde(default)]
324 pub registry: Option<RegistryConfig>,
325
326 #[serde(default)]
328 pub registries: MultiRegistryConfig,
329
330 #[serde(default)]
332 pub webhook: WebhookConfig,
333
334 #[serde(default)]
336 pub encryption: EncryptionConfigInner,
337
338 #[serde(default)]
340 pub storage: StorageConfigInner,
341
342 #[serde(default)]
350 pub rehearsal: RehearsalConfig,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, Default)]
368pub struct RehearsalConfig {
369 #[serde(default)]
372 pub enabled: bool,
373
374 #[serde(default)]
377 pub registry: Option<String>,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct RegistryConfig {
383 pub name: String,
385
386 pub api_base: String,
388
389 #[serde(default)]
391 pub index_base: Option<String>,
392
393 #[serde(default)]
399 pub token: Option<String>,
400
401 #[serde(default)]
403 pub default: bool,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, Default)]
408pub struct MultiRegistryConfig {
409 #[serde(default)]
411 pub registries: Vec<RegistryConfig>,
412
413 #[serde(default)]
415 pub default_registries: Vec<String>,
416}
417
418impl MultiRegistryConfig {
419 pub fn get_registries(&self) -> Vec<RegistryConfig> {
421 if self.registries.is_empty() {
422 vec![RegistryConfig {
424 name: "crates-io".to_string(),
425 api_base: "https://crates.io".to_string(),
426 index_base: Some("https://index.crates.io".to_string()),
427 token: None,
428 default: true,
429 }]
430 } else {
431 self.registries.clone()
432 }
433 }
434
435 pub fn get_default(&self) -> RegistryConfig {
437 self.registries
438 .iter()
439 .find(|r| r.default)
440 .or(self.registries.first())
441 .cloned()
442 .unwrap_or_else(|| RegistryConfig {
443 name: "crates-io".to_string(),
444 api_base: "https://crates.io".to_string(),
445 index_base: Some("https://index.crates.io".to_string()),
446 token: None,
447 default: true,
448 })
449 }
450
451 pub fn find_by_name(&self, name: &str) -> Option<RegistryConfig> {
453 self.registries.iter().find(|r| r.name == name).cloned()
454 }
455}
456
457#[derive(Debug, Default)]
466pub struct CliOverrides {
467 pub policy: Option<PublishPolicy>,
468 pub verify_mode: Option<VerifyMode>,
469 pub max_attempts: Option<u32>,
470 pub base_delay: Option<Duration>,
471 pub max_delay: Option<Duration>,
472 pub retry_strategy: Option<RetryStrategyType>,
473 pub retry_jitter: Option<f64>,
474 pub verify_timeout: Option<Duration>,
475 pub verify_poll_interval: Option<Duration>,
476 pub output_lines: Option<usize>,
477 pub lock_timeout: Option<Duration>,
478 pub state_dir: Option<PathBuf>,
479 pub readiness_method: Option<ReadinessMethod>,
480 pub readiness_timeout: Option<Duration>,
481 pub readiness_poll: Option<Duration>,
482 pub allow_dirty: bool,
483 pub skip_ownership_check: bool,
484 pub strict_ownership: bool,
485 pub no_verify: bool,
486 pub no_readiness: bool,
487 pub force: bool,
488 pub force_resume: bool,
489 pub parallel_enabled: bool,
490 pub max_concurrent: Option<usize>,
491 pub per_package_timeout: Option<Duration>,
492 pub webhook_url: Option<String>,
493 pub webhook_secret: Option<String>,
494 pub encrypt: bool,
495 pub encrypt_passphrase: Option<String>,
496 pub registries: Option<Vec<String>>,
498 pub all_registries: bool,
500 pub resume_from: Option<String>,
502 pub rehearsal_registry: Option<String>,
507 pub skip_rehearsal: bool,
510 pub rehearsal_smoke_install: Option<String>,
513}
514
515impl Default for ShipperConfig {
516 fn default() -> Self {
517 Self {
518 schema_version: default_schema_version(),
519 policy: PolicyConfig {
520 mode: PublishPolicy::default(),
521 },
522 verify: VerifyConfig {
523 mode: VerifyMode::default(),
524 },
525 readiness: ReadinessConfig::default(),
526 output: OutputConfig {
527 lines: default_output_lines(),
528 },
529 lock: LockConfig {
530 timeout: default_lock_timeout(),
531 },
532 retry: RetryConfig {
533 policy: RetryPolicy::Default,
534 max_attempts: default_max_attempts(),
535 base_delay: default_base_delay(),
536 max_delay: default_max_delay(),
537 strategy: RetryStrategyType::Exponential,
538 jitter: 0.5,
539 per_error: PerErrorConfig::default(),
540 },
541 flags: FlagsConfig {
542 allow_dirty: false,
543 skip_ownership_check: false,
544 strict_ownership: false,
545 },
546 parallel: ParallelConfig::default(),
547 state_dir: None,
548 registry: None,
549 registries: MultiRegistryConfig::default(),
550 webhook: WebhookConfig::default(),
551 encryption: EncryptionConfigInner::default(),
552 storage: StorageConfigInner::default(),
553 rehearsal: RehearsalConfig::default(),
554 }
555 }
556}
557
558fn default_output_lines() -> usize {
559 50
560}
561
562fn default_schema_version() -> String {
563 "shipper.config.v1".to_string()
564}
565
566fn default_lock_timeout() -> Duration {
567 Duration::from_secs(3600) }
569
570fn default_max_attempts() -> u32 {
571 6
572}
573
574fn default_base_delay() -> Duration {
575 Duration::from_secs(2)
576}
577
578fn default_max_delay() -> Duration {
579 Duration::from_secs(120) }
581
582impl ShipperConfig {
583 pub fn load_from_workspace(workspace_root: &Path) -> Result<Option<Self>> {
587 let config_path = workspace_root.join(".shipper.toml");
588 if !config_path.exists() {
589 return Ok(None);
590 }
591 Self::load_from_file(&config_path).map(Some)
592 }
593
594 pub fn load_from_file(path: &Path) -> Result<Self> {
596 let content = std::fs::read_to_string(path)
597 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
598
599 let config: ShipperConfig = toml::from_str(&content)
600 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
601
602 if let Err(e) = shipper_types::schema::validate_schema_version(
604 &config.schema_version,
605 "shipper.config.v1",
606 "config",
607 ) {
608 bail!("{} in file: {}", e, path.display());
609 }
610
611 Ok(config)
612 }
613
614 pub fn validate(&self) -> Result<()> {
616 shipper_types::schema::parse_schema_version(&self.schema_version)
618 .context("invalid schema_version format")?;
619
620 if self.output.lines == 0 {
622 bail!("output.lines must be greater than 0");
623 }
624
625 if self.retry.max_attempts == 0 {
627 bail!("retry.max_attempts must be greater than 0");
628 }
629
630 if self.retry.base_delay.is_zero() {
632 bail!("retry.base_delay must be greater than 0");
633 }
634
635 if self.retry.max_delay < self.retry.base_delay {
636 bail!("retry.max_delay must be greater than or equal to retry.base_delay");
637 }
638
639 if self.retry.jitter < 0.0 || self.retry.jitter > 1.0 {
641 bail!("retry.jitter must be between 0.0 and 1.0");
642 }
643
644 if self.lock.timeout.is_zero() {
646 bail!("lock.timeout must be greater than 0");
647 }
648
649 if self.readiness.max_total_wait.is_zero() {
651 bail!("readiness.max_total_wait must be greater than 0");
652 }
653
654 if self.readiness.poll_interval.is_zero() {
655 bail!("readiness.poll_interval must be greater than 0");
656 }
657
658 if self.readiness.jitter_factor < 0.0 || self.readiness.jitter_factor > 1.0 {
659 bail!("readiness.jitter_factor must be between 0.0 and 1.0");
660 }
661
662 if self.parallel.max_concurrent == 0 {
664 bail!("parallel.max_concurrent must be greater than 0");
665 }
666
667 if self.parallel.per_package_timeout.is_zero() {
668 bail!("parallel.per_package_timeout must be greater than 0");
669 }
670
671 if let Some(ref registry) = self.registry {
673 if registry.name.is_empty() {
674 bail!("registry.name cannot be empty");
675 }
676 if registry.api_base.is_empty() {
677 bail!("registry.api_base cannot be empty");
678 }
679 }
680
681 for reg in &self.registries.registries {
683 if reg.name.is_empty() {
684 bail!("registries[].name cannot be empty");
685 }
686 if reg.api_base.is_empty() {
687 bail!("registries[].api_base cannot be empty");
688 }
689 }
690
691 let default_count = self
693 .registries
694 .registries
695 .iter()
696 .filter(|r| r.default)
697 .count();
698 if default_count > 1 {
699 bail!("only one registry can be marked as default");
700 }
701
702 Ok(())
703 }
704
705 pub fn build_runtime_options(&self, cli: CliOverrides) -> RuntimeOptions {
710 runtime_options::build(self, cli)
711 }
712
713 pub fn default_toml_template() -> String {
715 r#"# Shipper configuration file
716# This file should be placed in your workspace root as .shipper.toml
717
718# Schema version for the configuration file
719schema_version = "shipper.config.v1"
720
721[policy]
722# Publishing policy: safe (verify+strict), balanced (verify when needed), or fast (no verify)
723mode = "safe"
724
725[verify]
726# Verify mode: workspace (default, safest), package (per-crate), or none (no verify)
727mode = "workspace"
728
729[readiness]
730# Enable readiness checks (wait for registry visibility after publish)
731enabled = true
732# Method for checking version visibility: api (fast), index (slower, more accurate), both (slowest, most reliable)
733method = "api"
734# Initial delay before first poll
735initial_delay = "1s"
736# Maximum delay between polls
737max_delay = "60s"
738# Maximum total time to wait for visibility
739max_total_wait = "5m"
740# Base poll interval
741poll_interval = "2s"
742# Jitter factor for randomized delays (0.0 = no jitter, 1.0 = full jitter)
743jitter_factor = 0.5
744
745[output]
746# Number of output lines to capture for evidence
747lines = 50
748
749[lock]
750# Lock timeout duration (locks older than this are considered stale)
751timeout = "1h"
752
753[retry]
754# Retry policy: default (balanced), aggressive, conservative, or custom
755# - default: exponential backoff with 6 attempts, 2s base, 2m max
756# - aggressive: exponential backoff with 10 attempts, 500ms base, 30s max
757# - conservative: linear backoff with 3 attempts, 5s base, 60s max
758# - custom: uses explicit strategy settings below
759policy = "default"
760# Max attempts per crate publish step (used when policy is custom)
761max_attempts = 6
762# Base backoff delay
763base_delay = "2s"
764# Max backoff delay
765max_delay = "2m"
766# Strategy type: immediate, exponential, linear, constant
767strategy = "exponential"
768# Jitter factor for randomized delays (0.0 = no jitter, 1.0 = full jitter)
769jitter = 0.5
770
771# Per-error-type retry configuration (optional)
772# Uncomment and customize to override retry behavior for specific error types
773# [retry.per_error.retryable]
774# strategy = "immediate"
775# max_attempts = 10
776# base_delay = "0s"
777# max_delay = "1s"
778# jitter = 0.0
779
780# [retry.per_error.ambiguous]
781# strategy = "exponential"
782# max_attempts = 5
783# base_delay = "1s"
784# max_delay = "60s"
785# jitter = 0.3
786
787[flags]
788# Allow publishing from a dirty git working tree (not recommended)
789allow_dirty = false
790# Skip owners/permissions preflight (not recommended)
791skip_ownership_check = false
792# Fail preflight if ownership checks fail (recommended)
793strict_ownership = false
794
795[parallel]
796# Enable parallel publishing (default: false for sequential)
797enabled = false
798# Maximum number of concurrent publish operations (default: 4)
799max_concurrent = 4
800# Timeout per package publish operation (default: 30 minutes)
801per_package_timeout = "30m"
802
803# Optional: Custom registry configuration
804# [registry]
805# name = "crates-io"
806# api_base = "https://crates.io"
807
808# Optional: Webhook notifications for publish events
809# [webhook]
810# Enable webhook notifications (default: false - disabled)
811# enabled = false
812# URL to send POST requests to
813# url = "https://your-webhook-endpoint.com/webhook"
814# Optional secret for signing webhook payloads
815# secret = "your-webhook-secret"
816# Request timeout (default: 30s)
817# timeout = "30s"
818"#.to_string()
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 #[test]
827 fn test_default_config() {
828 let config = ShipperConfig::default();
829 assert_eq!(config.policy.mode, PublishPolicy::Safe);
830 assert_eq!(config.verify.mode, VerifyMode::Workspace);
831 assert_eq!(config.output.lines, 50);
832 assert_eq!(config.retry.max_attempts, 6);
833 assert!(!config.flags.allow_dirty);
834 assert!(config.validate().is_ok());
835 }
836
837 #[test]
838 fn test_validate_invalid_output_lines() {
839 let mut config = ShipperConfig::default();
840 config.output.lines = 0;
841 assert!(config.validate().is_err());
842 }
843
844 #[test]
845 fn test_validate_invalid_max_attempts() {
846 let mut config = ShipperConfig::default();
847 config.retry.max_attempts = 0;
848 assert!(config.validate().is_err());
849 }
850
851 #[test]
852 fn test_validate_invalid_delays() {
853 let mut config = ShipperConfig::default();
854 config.retry.base_delay = Duration::ZERO;
855 assert!(config.validate().is_err());
856
857 config.retry.base_delay = Duration::from_secs(1);
858 config.retry.max_delay = Duration::from_millis(500);
859 assert!(config.validate().is_err());
860 }
861
862 #[test]
863 fn test_validate_invalid_jitter_factor() {
864 let mut config = ShipperConfig::default();
865 config.readiness.jitter_factor = 1.5;
866 assert!(config.validate().is_err());
867
868 config.readiness.jitter_factor = -0.1;
869 assert!(config.validate().is_err());
870 }
871
872 #[test]
873 fn test_validate_invalid_registry() {
874 let mut config = ShipperConfig {
875 schema_version: default_schema_version(),
876 registry: Some(RegistryConfig {
877 name: String::new(),
878 api_base: "https://crates.io".to_string(),
879 index_base: None,
880 token: None,
881 default: false,
882 }),
883 ..Default::default()
884 };
885 assert!(config.validate().is_err());
886
887 config.registry = Some(RegistryConfig {
888 name: "crates-io".to_string(),
889 api_base: String::new(),
890 index_base: None,
891 token: None,
892 default: false,
893 });
894 assert!(config.validate().is_err());
895 }
896
897 #[test]
898 fn test_parse_toml_config() {
899 let toml = r#"
900[policy]
901mode = "fast"
902
903[verify]
904mode = "none"
905
906[readiness]
907enabled = false
908method = "api"
909initial_delay = "1s"
910max_delay = "60s"
911max_total_wait = "5m"
912poll_interval = "2s"
913jitter_factor = 0.5
914
915[output]
916lines = 100
917
918[lock]
919timeout = "30m"
920
921[retry]
922max_attempts = 3
923base_delay = "1s"
924max_delay = "30s"
925
926[flags]
927allow_dirty = true
928skip_ownership_check = true
929"#;
930
931 let config: ShipperConfig = toml::from_str(toml).unwrap();
932 assert_eq!(config.policy.mode, PublishPolicy::Fast);
933 assert_eq!(config.verify.mode, VerifyMode::None);
934 assert!(!config.readiness.enabled);
935 assert_eq!(config.output.lines, 100);
936 assert_eq!(config.lock.timeout, Duration::from_secs(1800));
937 assert_eq!(config.retry.max_attempts, 3);
938 assert!(config.flags.allow_dirty);
939 assert!(config.flags.skip_ownership_check);
940 }
941
942 #[test]
943 fn test_parse_toml_with_registry() {
944 let toml = r#"
945[registry]
946name = "my-registry"
947api_base = "https://my-registry.example.com"
948"#;
949
950 let config: ShipperConfig = toml::from_str(toml).unwrap();
951 assert!(config.registry.is_some());
952 let registry = config.registry.unwrap();
953 assert_eq!(registry.name, "my-registry");
954 assert_eq!(registry.api_base, "https://my-registry.example.com");
955 }
956
957 #[test]
960 fn rehearsal_defaults_are_disabled_and_empty() {
961 let config: ShipperConfig = toml::from_str("").unwrap();
963 assert!(
964 !config.rehearsal.enabled,
965 "rehearsal should default to disabled (opt-in until phase-2 execution lands)"
966 );
967 assert!(
968 config.rehearsal.registry.is_none(),
969 "rehearsal registry default is None"
970 );
971 }
972
973 #[test]
974 fn rehearsal_section_parses_enabled_with_registry_name() {
975 let toml = r#"
976[rehearsal]
977enabled = true
978registry = "kellnr-local"
979"#;
980 let config: ShipperConfig = toml::from_str(toml).unwrap();
981 assert!(config.rehearsal.enabled);
982 assert_eq!(
983 config.rehearsal.registry.as_deref(),
984 Some("kellnr-local"),
985 "rehearsal.registry should parse the named registry reference"
986 );
987 }
988
989 #[test]
990 fn rehearsal_section_partial_parses_with_field_defaults() {
991 let toml = r#"
993[rehearsal]
994enabled = true
995"#;
996 let config: ShipperConfig = toml::from_str(toml).unwrap();
997 assert!(config.rehearsal.enabled);
998 assert!(config.rehearsal.registry.is_none());
999 }
1000
1001 #[test]
1002 fn rehearsal_cli_overrides_default_to_empty() {
1003 let overrides = CliOverrides::default();
1005 assert!(overrides.rehearsal_registry.is_none());
1006 assert!(!overrides.skip_rehearsal);
1007 }
1008
1009 #[test]
1010 fn test_parse_toml_with_parallel() {
1011 let toml = r#"
1012[parallel]
1013enabled = true
1014max_concurrent = 8
1015per_package_timeout = "1h"
1016"#;
1017
1018 let config: ShipperConfig = toml::from_str(toml).unwrap();
1019 assert!(config.parallel.enabled);
1020 assert_eq!(config.parallel.max_concurrent, 8);
1021 assert_eq!(
1022 config.parallel.per_package_timeout,
1023 Duration::from_secs(3600)
1024 );
1025 }
1026
1027 #[test]
1028 fn test_parse_toml_with_partial_readiness_uses_defaults() {
1029 let toml = r#"
1030[readiness]
1031method = "both"
1032"#;
1033
1034 let config: ShipperConfig = toml::from_str(toml).unwrap();
1035 assert_eq!(config.readiness.method, ReadinessMethod::Both);
1036 assert!(config.readiness.enabled);
1037 assert_eq!(config.readiness.initial_delay, Duration::from_secs(1));
1038 assert_eq!(config.readiness.max_delay, Duration::from_secs(60));
1039 assert_eq!(config.readiness.max_total_wait, Duration::from_secs(300));
1040 assert_eq!(config.readiness.poll_interval, Duration::from_secs(2));
1041 assert_eq!(config.readiness.jitter_factor, 0.5);
1042 }
1043
1044 #[test]
1045 fn test_parse_toml_with_partial_parallel_uses_defaults() {
1046 let toml = r#"
1047[parallel]
1048enabled = true
1049"#;
1050
1051 let config: ShipperConfig = toml::from_str(toml).unwrap();
1052 assert!(config.parallel.enabled);
1053 assert_eq!(config.parallel.max_concurrent, 4);
1054 assert_eq!(
1055 config.parallel.per_package_timeout,
1056 Duration::from_secs(1800)
1057 );
1058 }
1059
1060 #[test]
1061 fn test_parse_toml_with_partial_sections_remains_valid() {
1062 let toml = r#"
1063[readiness]
1064method = "both"
1065
1066[parallel]
1067enabled = true
1068"#;
1069
1070 let config: ShipperConfig = toml::from_str(toml).unwrap();
1071 assert_eq!(config.output.lines, 50);
1072 assert_eq!(config.retry.max_attempts, 6);
1073 assert_eq!(config.lock.timeout, Duration::from_secs(3600));
1074 assert!(config.validate().is_ok());
1075 }
1076
1077 #[test]
1078 fn test_build_runtime_options_cli_overrides_config() {
1079 let config = ShipperConfig {
1080 schema_version: default_schema_version(),
1081 retry: RetryConfig {
1082 policy: RetryPolicy::Custom,
1083 max_attempts: 10,
1084 base_delay: Duration::from_secs(5),
1085 max_delay: Duration::from_secs(300),
1086 strategy: RetryStrategyType::Exponential,
1087 jitter: 0.5,
1088 per_error: PerErrorConfig::default(),
1089 },
1090 output: OutputConfig { lines: 100 },
1091 policy: PolicyConfig {
1092 mode: PublishPolicy::Balanced,
1093 },
1094 ..Default::default()
1095 };
1096
1097 let cli = CliOverrides {
1098 max_attempts: Some(3),
1099 policy: Some(PublishPolicy::Fast),
1100 output_lines: Some(25),
1101 ..Default::default()
1102 };
1103
1104 let opts = config.build_runtime_options(cli);
1105 assert_eq!(opts.max_attempts, 3, "CLI max_attempts should win");
1106 assert_eq!(opts.policy, PublishPolicy::Fast, "CLI policy should win");
1107 assert_eq!(opts.output_lines, 25, "CLI output_lines should win");
1108 }
1109
1110 #[test]
1111 fn test_build_runtime_options_config_used_when_cli_none() {
1112 let config = ShipperConfig {
1113 schema_version: default_schema_version(),
1114 retry: RetryConfig {
1115 policy: RetryPolicy::Custom,
1116 max_attempts: 10,
1117 base_delay: Duration::from_secs(5),
1118 max_delay: Duration::from_secs(300),
1119 strategy: RetryStrategyType::Exponential,
1120 jitter: 0.5,
1121 per_error: PerErrorConfig::default(),
1122 },
1123 output: OutputConfig { lines: 100 },
1124 policy: PolicyConfig {
1125 mode: PublishPolicy::Balanced,
1126 },
1127 verify: VerifyConfig {
1128 mode: VerifyMode::Package,
1129 },
1130 lock: LockConfig {
1131 timeout: Duration::from_secs(1800),
1132 },
1133 state_dir: Some(PathBuf::from("custom-state")),
1134 ..Default::default()
1135 };
1136
1137 let cli = CliOverrides::default();
1138
1139 let opts = config.build_runtime_options(cli);
1140 assert_eq!(opts.max_attempts, 10, "config max_attempts should apply");
1141 assert_eq!(opts.base_delay, Duration::from_secs(5));
1142 assert_eq!(opts.max_delay, Duration::from_secs(300));
1143 assert_eq!(opts.output_lines, 100);
1144 assert_eq!(opts.policy, PublishPolicy::Balanced);
1145 assert_eq!(opts.verify_mode, VerifyMode::Package);
1146 assert_eq!(opts.lock_timeout, Duration::from_secs(1800));
1147 assert_eq!(opts.state_dir, PathBuf::from("custom-state"));
1148 }
1149
1150 #[test]
1151 fn test_build_runtime_options_booleans_are_ored() {
1152 let config = ShipperConfig {
1154 flags: FlagsConfig {
1155 allow_dirty: true,
1156 skip_ownership_check: false,
1157 strict_ownership: true,
1158 },
1159 ..Default::default()
1160 };
1161
1162 let cli = CliOverrides {
1163 skip_ownership_check: true,
1164 ..Default::default()
1165 };
1166
1167 let opts = config.build_runtime_options(cli);
1168 assert!(opts.allow_dirty, "config allow_dirty should apply");
1169 assert!(opts.skip_ownership_check, "CLI skip_ownership should apply");
1170 assert!(
1171 opts.strict_ownership,
1172 "config strict_ownership should apply"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_build_runtime_options_defaults_when_no_config() {
1178 let config = ShipperConfig::default();
1179 let cli = CliOverrides::default();
1180
1181 let opts = config.build_runtime_options(cli);
1182 assert_eq!(opts.max_attempts, 6);
1183 assert_eq!(opts.base_delay, Duration::from_secs(2));
1184 assert_eq!(opts.max_delay, Duration::from_secs(120));
1185 assert_eq!(opts.policy, PublishPolicy::Safe);
1186 assert_eq!(opts.verify_mode, VerifyMode::Workspace);
1187 assert_eq!(opts.output_lines, 50);
1188 assert_eq!(opts.state_dir, PathBuf::from(".shipper"));
1189 assert!(!opts.allow_dirty);
1190 assert!(!opts.no_verify);
1191 assert!(opts.readiness.enabled);
1192 }
1193
1194 #[test]
1195 fn test_build_runtime_options_no_readiness_disables() {
1196 let config = ShipperConfig::default(); let cli = CliOverrides {
1199 no_readiness: true,
1200 ..Default::default()
1201 };
1202
1203 let opts = config.build_runtime_options(cli);
1204 assert!(!opts.readiness.enabled);
1205 }
1206
1207 #[test]
1208 fn test_build_runtime_options_parallel_merge() {
1209 let config = ShipperConfig {
1210 parallel: ParallelConfig {
1211 enabled: true,
1212 max_concurrent: 8,
1213 per_package_timeout: Duration::from_secs(7200),
1214 },
1215 ..Default::default()
1216 };
1217
1218 let cli = CliOverrides::default();
1220 let opts = config.build_runtime_options(cli);
1221 assert!(opts.parallel.enabled);
1222 assert_eq!(opts.parallel.max_concurrent, 8);
1223 assert_eq!(opts.parallel.per_package_timeout, Duration::from_secs(7200));
1224
1225 let cli2 = CliOverrides {
1227 max_concurrent: Some(2),
1228 ..Default::default()
1229 };
1230 let opts2 = config.build_runtime_options(cli2);
1231 assert!(opts2.parallel.enabled); assert_eq!(opts2.parallel.max_concurrent, 2); }
1234
1235 mod snapshot_tests {
1236 use super::*;
1237
1238 #[test]
1239 fn snapshot_default_config() {
1240 let config = ShipperConfig::default();
1241 insta::assert_yaml_snapshot!("default_config", config);
1242 }
1243
1244 #[test]
1245 fn snapshot_config_all_fields_set() {
1246 let config = ShipperConfig {
1247 schema_version: "shipper.config.v1".to_string(),
1248 policy: PolicyConfig {
1249 mode: PublishPolicy::Fast,
1250 },
1251 verify: VerifyConfig {
1252 mode: VerifyMode::None,
1253 },
1254 readiness: ReadinessConfig {
1255 enabled: false,
1256 method: ReadinessMethod::Both,
1257 initial_delay: Duration::from_secs(5),
1258 max_delay: Duration::from_secs(120),
1259 max_total_wait: Duration::from_secs(600),
1260 poll_interval: Duration::from_secs(10),
1261 jitter_factor: 0.3,
1262 index_path: Some(std::path::PathBuf::from("/tmp/index")),
1263 prefer_index: true,
1264 },
1265 output: OutputConfig { lines: 200 },
1266 lock: LockConfig {
1267 timeout: Duration::from_secs(7200),
1268 },
1269 retry: RetryConfig {
1270 policy: RetryPolicy::Aggressive,
1271 max_attempts: 10,
1272 base_delay: Duration::from_millis(500),
1273 max_delay: Duration::from_secs(30),
1274 strategy: RetryStrategyType::Linear,
1275 jitter: 0.1,
1276 per_error: PerErrorConfig::default(),
1277 },
1278 flags: FlagsConfig {
1279 allow_dirty: true,
1280 skip_ownership_check: true,
1281 strict_ownership: true,
1282 },
1283 parallel: ParallelConfig {
1284 enabled: true,
1285 max_concurrent: 8,
1286 per_package_timeout: Duration::from_secs(3600),
1287 },
1288 state_dir: Some(std::path::PathBuf::from("/custom/state")),
1289 registry: Some(RegistryConfig {
1290 name: "my-registry".to_string(),
1291 api_base: "https://my-registry.example.com".to_string(),
1292 index_base: Some("https://index.my-registry.example.com".to_string()),
1293 token: None,
1294 default: true,
1295 }),
1296 registries: MultiRegistryConfig::default(),
1297 webhook: WebhookConfig::default(),
1298 encryption: EncryptionConfigInner {
1299 enabled: true,
1300 passphrase: None,
1301 env_key: Some("MY_ENCRYPT_KEY".to_string()),
1302 },
1303 storage: StorageConfigInner {
1304 storage_type: StorageType::default(),
1305 bucket: Some("my-bucket".to_string()),
1306 region: Some("us-east-1".to_string()),
1307 base_path: Some("releases/".to_string()),
1308 endpoint: None,
1309 access_key_id: None,
1310 secret_access_key: None,
1311 },
1312 rehearsal: RehearsalConfig::default(),
1313 };
1314 insta::assert_yaml_snapshot!("config_all_fields", config);
1315 }
1316
1317 #[test]
1318 fn snapshot_validation_error_zero_output_lines() {
1319 let mut config = ShipperConfig::default();
1320 config.output.lines = 0;
1321 let err = config.validate().unwrap_err();
1322 insta::assert_yaml_snapshot!("validation_error_zero_output_lines", err.to_string());
1323 }
1324
1325 #[test]
1326 fn snapshot_validation_error_zero_max_attempts() {
1327 let mut config = ShipperConfig::default();
1328 config.retry.max_attempts = 0;
1329 let err = config.validate().unwrap_err();
1330 insta::assert_yaml_snapshot!("validation_error_zero_max_attempts", err.to_string());
1331 }
1332
1333 #[test]
1334 fn snapshot_validation_error_zero_base_delay() {
1335 let mut config = ShipperConfig::default();
1336 config.retry.base_delay = Duration::ZERO;
1337 let err = config.validate().unwrap_err();
1338 insta::assert_yaml_snapshot!("validation_error_zero_base_delay", err.to_string());
1339 }
1340
1341 #[test]
1342 fn snapshot_validation_error_max_delay_less_than_base() {
1343 let mut config = ShipperConfig::default();
1344 config.retry.base_delay = Duration::from_secs(10);
1345 config.retry.max_delay = Duration::from_secs(5);
1346 let err = config.validate().unwrap_err();
1347 insta::assert_yaml_snapshot!("validation_error_max_delay_lt_base", err.to_string());
1348 }
1349
1350 #[test]
1351 fn snapshot_validation_error_jitter_out_of_range() {
1352 let mut config = ShipperConfig::default();
1353 config.retry.jitter = 1.5;
1354 let err = config.validate().unwrap_err();
1355 insta::assert_yaml_snapshot!("validation_error_jitter_out_of_range", err.to_string());
1356 }
1357
1358 #[test]
1359 fn snapshot_validation_error_empty_registry_name() {
1360 let config = ShipperConfig {
1361 registry: Some(RegistryConfig {
1362 name: String::new(),
1363 api_base: "https://crates.io".to_string(),
1364 index_base: None,
1365 token: None,
1366 default: false,
1367 }),
1368 ..ShipperConfig::default()
1369 };
1370 let err = config.validate().unwrap_err();
1371 insta::assert_yaml_snapshot!("validation_error_empty_registry_name", err.to_string());
1372 }
1373
1374 #[test]
1375 fn snapshot_toml_roundtrip() {
1376 let toml_input = r#"
1377schema_version = "shipper.config.v1"
1378
1379[policy]
1380mode = "balanced"
1381
1382[verify]
1383mode = "package"
1384
1385[readiness]
1386enabled = true
1387method = "index"
1388initial_delay = "2s"
1389max_delay = "30s"
1390max_total_wait = "3m"
1391poll_interval = "5s"
1392jitter_factor = 0.25
1393
1394[output]
1395lines = 75
1396
1397[lock]
1398timeout = "45m"
1399
1400[retry]
1401policy = "conservative"
1402max_attempts = 3
1403base_delay = "5s"
1404max_delay = "1m"
1405strategy = "linear"
1406jitter = 0.2
1407
1408[flags]
1409allow_dirty = false
1410skip_ownership_check = false
1411strict_ownership = true
1412
1413[parallel]
1414enabled = true
1415max_concurrent = 2
1416per_package_timeout = "15m"
1417"#;
1418
1419 let parsed: ShipperConfig = toml::from_str(toml_input).unwrap();
1420 let re_serialized = toml::to_string_pretty(&parsed).unwrap();
1421 let re_parsed: ShipperConfig = toml::from_str(&re_serialized).unwrap();
1422 insta::assert_yaml_snapshot!("toml_roundtrip_parsed", re_parsed);
1423 }
1424
1425 #[test]
1426 fn snapshot_default_toml_template() {
1427 let template = ShipperConfig::default_toml_template();
1428 insta::assert_snapshot!("default_toml_template", template);
1429 }
1430
1431 #[test]
1432 fn snapshot_validation_error_zero_lock_timeout() {
1433 let mut config = ShipperConfig::default();
1434 config.lock.timeout = Duration::ZERO;
1435 let err = config.validate().unwrap_err();
1436 insta::assert_yaml_snapshot!("validation_error_zero_lock_timeout", err.to_string());
1437 }
1438
1439 #[test]
1440 fn snapshot_validation_error_zero_per_package_timeout() {
1441 let mut config = ShipperConfig::default();
1442 config.parallel.per_package_timeout = Duration::ZERO;
1443 let err = config.validate().unwrap_err();
1444 insta::assert_yaml_snapshot!(
1445 "validation_error_zero_per_package_timeout",
1446 err.to_string()
1447 );
1448 }
1449
1450 #[test]
1451 fn snapshot_validation_error_zero_readiness_timeout() {
1452 let mut config = ShipperConfig::default();
1453 config.readiness.max_total_wait = Duration::ZERO;
1454 let err = config.validate().unwrap_err();
1455 insta::assert_yaml_snapshot!(
1456 "validation_error_zero_readiness_timeout",
1457 err.to_string()
1458 );
1459 }
1460
1461 #[test]
1462 fn snapshot_validation_error_zero_readiness_poll_interval() {
1463 let mut config = ShipperConfig::default();
1464 config.readiness.poll_interval = Duration::ZERO;
1465 let err = config.validate().unwrap_err();
1466 insta::assert_yaml_snapshot!(
1467 "validation_error_zero_readiness_poll_interval",
1468 err.to_string()
1469 );
1470 }
1471
1472 #[test]
1473 fn snapshot_merge_cli_overrides_file_values() {
1474 let config = ShipperConfig {
1475 policy: PolicyConfig {
1476 mode: PublishPolicy::Safe,
1477 },
1478 retry: RetryConfig {
1479 policy: RetryPolicy::Custom,
1480 max_attempts: 3,
1481 base_delay: Duration::from_secs(2),
1482 max_delay: Duration::from_secs(60),
1483 strategy: RetryStrategyType::Exponential,
1484 jitter: 0.1,
1485 per_error: PerErrorConfig::default(),
1486 },
1487 output: OutputConfig { lines: 50 },
1488 lock: LockConfig {
1489 timeout: Duration::from_secs(1800),
1490 },
1491 parallel: ParallelConfig {
1492 enabled: false,
1493 max_concurrent: 4,
1494 per_package_timeout: Duration::from_secs(600),
1495 },
1496 ..ShipperConfig::default()
1497 };
1498
1499 let cli = CliOverrides {
1500 policy: Some(PublishPolicy::Fast),
1501 max_attempts: Some(10),
1502 output_lines: Some(200),
1503 lock_timeout: Some(Duration::from_secs(7200)),
1504 parallel_enabled: true,
1505 max_concurrent: Some(8),
1506 allow_dirty: true,
1507 ..CliOverrides::default()
1508 };
1509
1510 let merged = config.build_runtime_options(cli);
1511 insta::assert_debug_snapshot!("merge_cli_overrides_file_values", merged);
1512 }
1513 }
1514
1515 mod error_message_snapshots {
1518 use super::*;
1519
1520 #[test]
1521 fn snapshot_error_message_empty_registry_api_base() {
1522 let config = ShipperConfig {
1523 registry: Some(RegistryConfig {
1524 name: "my-registry".to_string(),
1525 api_base: String::new(),
1526 index_base: None,
1527 token: None,
1528 default: false,
1529 }),
1530 ..ShipperConfig::default()
1531 };
1532 let err = config.validate().unwrap_err();
1533 insta::assert_snapshot!("error_msg_empty_registry_api_base", err.to_string());
1534 }
1535
1536 #[test]
1537 fn snapshot_error_message_negative_jitter() {
1538 let mut config = ShipperConfig::default();
1539 config.retry.jitter = -0.1;
1540 let err = config.validate().unwrap_err();
1541 insta::assert_snapshot!("error_msg_negative_jitter", err.to_string());
1542 }
1543
1544 #[test]
1545 fn snapshot_error_message_readiness_jitter_out_of_range() {
1546 let mut config = ShipperConfig::default();
1547 config.readiness.jitter_factor = 2.0;
1548 let err = config.validate().unwrap_err();
1549 insta::assert_snapshot!("error_msg_readiness_jitter_out_of_range", err.to_string());
1550 }
1551
1552 #[test]
1553 fn snapshot_error_message_zero_max_concurrent() {
1554 let mut config = ShipperConfig::default();
1555 config.parallel.max_concurrent = 0;
1556 let err = config.validate().unwrap_err();
1557 insta::assert_snapshot!("error_msg_zero_max_concurrent", err.to_string());
1558 }
1559
1560 #[test]
1561 fn snapshot_error_message_registries_empty_name() {
1562 let config = ShipperConfig {
1563 registries: MultiRegistryConfig {
1564 registries: vec![RegistryConfig {
1565 name: String::new(),
1566 api_base: "https://example.com".to_string(),
1567 index_base: None,
1568 token: None,
1569 default: false,
1570 }],
1571 default_registries: vec![],
1572 },
1573 ..ShipperConfig::default()
1574 };
1575 let err = config.validate().unwrap_err();
1576 insta::assert_snapshot!("error_msg_registries_empty_name", err.to_string());
1577 }
1578
1579 #[test]
1580 fn snapshot_error_message_registries_empty_api_base() {
1581 let config = ShipperConfig {
1582 registries: MultiRegistryConfig {
1583 registries: vec![RegistryConfig {
1584 name: "my-reg".to_string(),
1585 api_base: String::new(),
1586 index_base: None,
1587 token: None,
1588 default: false,
1589 }],
1590 default_registries: vec![],
1591 },
1592 ..ShipperConfig::default()
1593 };
1594 let err = config.validate().unwrap_err();
1595 insta::assert_snapshot!("error_msg_registries_empty_api_base", err.to_string());
1596 }
1597
1598 #[test]
1599 fn snapshot_error_message_multiple_default_registries() {
1600 let config = ShipperConfig {
1601 registries: MultiRegistryConfig {
1602 registries: vec![
1603 RegistryConfig {
1604 name: "reg-a".to_string(),
1605 api_base: "https://a.example.com".to_string(),
1606 index_base: None,
1607 token: None,
1608 default: true,
1609 },
1610 RegistryConfig {
1611 name: "reg-b".to_string(),
1612 api_base: "https://b.example.com".to_string(),
1613 index_base: None,
1614 token: None,
1615 default: true,
1616 },
1617 ],
1618 default_registries: vec![],
1619 },
1620 ..ShipperConfig::default()
1621 };
1622 let err = config.validate().unwrap_err();
1623 insta::assert_snapshot!("error_msg_multiple_default_registries", err.to_string());
1624 }
1625 }
1626
1627 #[cfg(test)]
1628 mod proptests {
1629 use super::*;
1630 use proptest::prelude::*;
1631
1632 fn arb_policy() -> impl Strategy<Value = PublishPolicy> {
1633 prop_oneof![
1634 Just(PublishPolicy::Safe),
1635 Just(PublishPolicy::Balanced),
1636 Just(PublishPolicy::Fast),
1637 ]
1638 }
1639
1640 fn arb_verify_mode() -> impl Strategy<Value = VerifyMode> {
1641 prop_oneof![
1642 Just(VerifyMode::Workspace),
1643 Just(VerifyMode::Package),
1644 Just(VerifyMode::None),
1645 ]
1646 }
1647
1648 fn arb_retry_policy() -> impl Strategy<Value = RetryPolicy> {
1649 prop_oneof![
1650 Just(RetryPolicy::Default),
1651 Just(RetryPolicy::Aggressive),
1652 Just(RetryPolicy::Conservative),
1653 Just(RetryPolicy::Custom),
1654 ]
1655 }
1656
1657 fn arb_retry_strategy() -> impl Strategy<Value = RetryStrategyType> {
1658 prop_oneof![
1659 Just(RetryStrategyType::Immediate),
1660 Just(RetryStrategyType::Exponential),
1661 Just(RetryStrategyType::Linear),
1662 Just(RetryStrategyType::Constant),
1663 ]
1664 }
1665
1666 fn arb_readiness_method() -> impl Strategy<Value = ReadinessMethod> {
1667 prop_oneof![
1668 Just(ReadinessMethod::Api),
1669 Just(ReadinessMethod::Index),
1670 Just(ReadinessMethod::Both),
1671 ]
1672 }
1673
1674 fn arb_valid_config() -> impl Strategy<Value = ShipperConfig> {
1676 let enums = (
1677 arb_policy(),
1678 arb_verify_mode(),
1679 arb_retry_policy(),
1680 arb_retry_strategy(),
1681 arb_readiness_method(),
1682 );
1683 let retry_nums = (
1684 1u32..100, 1u64..3600, 0u64..3600, 0.0f64..=1.0, );
1689 let config_nums = (
1690 1usize..500, 1u64..7200, 1usize..32, 1u64..7200, );
1695 let booleans = (
1696 any::<bool>(), any::<bool>(), any::<bool>(), any::<bool>(), any::<bool>(), );
1702 let readiness_nums = (
1703 1u64..600, 1u64..600, 1u64..600, 1u64..60, 0.0f64..=1.0, );
1709
1710 (enums, retry_nums, config_nums, booleans, readiness_nums).prop_map(
1711 |(
1712 (policy, verify, retry_policy, retry_strategy, readiness_method),
1713 (max_attempts, base_delay, extra_delay, jitter),
1714 (output_lines, lock_timeout, max_concurrent, per_package_timeout),
1715 (
1716 allow_dirty,
1717 skip_ownership,
1718 strict_ownership,
1719 readiness_enabled,
1720 parallel_enabled,
1721 ),
1722 (r_initial, r_max_delay, r_max_total, r_poll, r_jitter),
1723 )| {
1724 ShipperConfig {
1725 schema_version: default_schema_version(),
1726 policy: PolicyConfig { mode: policy },
1727 verify: VerifyConfig { mode: verify },
1728 readiness: ReadinessConfig {
1729 enabled: readiness_enabled,
1730 method: readiness_method,
1731 initial_delay: Duration::from_secs(r_initial),
1732 max_delay: Duration::from_secs(r_max_delay),
1733 max_total_wait: Duration::from_secs(r_max_total),
1734 poll_interval: Duration::from_secs(r_poll),
1735 jitter_factor: r_jitter,
1736 index_path: None,
1737 prefer_index: false,
1738 },
1739 output: OutputConfig {
1740 lines: output_lines,
1741 },
1742 lock: LockConfig {
1743 timeout: Duration::from_secs(lock_timeout),
1744 },
1745 retry: RetryConfig {
1746 policy: retry_policy,
1747 max_attempts,
1748 base_delay: Duration::from_secs(base_delay),
1749 max_delay: Duration::from_secs(base_delay + extra_delay),
1750 strategy: retry_strategy,
1751 jitter,
1752 per_error: PerErrorConfig::default(),
1753 },
1754 flags: FlagsConfig {
1755 allow_dirty,
1756 skip_ownership_check: skip_ownership,
1757 strict_ownership,
1758 },
1759 parallel: ParallelConfig {
1760 enabled: parallel_enabled,
1761 max_concurrent,
1762 per_package_timeout: Duration::from_secs(per_package_timeout),
1763 },
1764 state_dir: None,
1765 registry: None,
1766 registries: MultiRegistryConfig::default(),
1767 webhook: WebhookConfig::default(),
1768 encryption: EncryptionConfigInner::default(),
1769 storage: StorageConfigInner::default(),
1770 rehearsal: RehearsalConfig::default(),
1771 }
1772 },
1773 )
1774 }
1775
1776 proptest! {
1777 #[test]
1778 fn cli_max_attempts_overrides_custom_retry_settings(
1779 cfg_max_attempts in 1u32..300,
1780 cli_max_attempts in proptest::option::of(1u32..300),
1781 max_delay in 1u64..10_000,
1782 base_delay in 1u64..5_000,
1783 no_readiness in any::<bool>(),
1784 allow_dirty in any::<bool>(),
1785 skip_ownership in any::<bool>(),
1786 strict_ownership in any::<bool>(),
1787 ) {
1788 let config = ShipperConfig {
1789 schema_version: default_schema_version(),
1790 retry: RetryConfig {
1791 policy: RetryPolicy::Custom,
1792 max_attempts: cfg_max_attempts,
1793 base_delay: Duration::from_millis(base_delay),
1794 max_delay: Duration::from_millis(max_delay.max(base_delay)),
1795 strategy: RetryStrategyType::Exponential,
1796 jitter: 0.5,
1797 per_error: PerErrorConfig::default(),
1798 },
1799 flags: FlagsConfig {
1800 allow_dirty,
1801 skip_ownership_check: skip_ownership,
1802 strict_ownership,
1803 },
1804 readiness: ReadinessConfig { enabled: !no_readiness, ..Default::default() },
1805 parallel: ParallelConfig {
1806 enabled: true,
1807 max_concurrent: 4,
1808 per_package_timeout: Duration::from_secs(600),
1809 },
1810 ..Default::default()
1811 };
1812
1813 let cli = CliOverrides {
1814 max_attempts: cli_max_attempts,
1815 output_lines: Some(73),
1816 no_readiness,
1817 allow_dirty,
1818 skip_ownership_check: skip_ownership,
1819 strict_ownership,
1820 ..Default::default()
1821 };
1822
1823 let opts = config.build_runtime_options(cli);
1824
1825 assert_eq!(
1826 opts.max_attempts,
1827 cli_max_attempts.unwrap_or(cfg_max_attempts)
1828 );
1829 assert_eq!(opts.allow_dirty, allow_dirty);
1830 assert_eq!(opts.skip_ownership_check, skip_ownership);
1831 assert_eq!(opts.strict_ownership, strict_ownership);
1832 assert_eq!(opts.readiness.enabled, !no_readiness);
1833 assert_eq!(opts.parallel.max_concurrent, 4);
1834 }
1835
1836 #[test]
1838 fn toml_roundtrip_preserves_config(config in arb_valid_config()) {
1839 let toml1 = toml::to_string_pretty(&config)
1840 .expect("first serialize must succeed");
1841 let parsed: ShipperConfig = toml::from_str(&toml1)
1842 .expect("deserialize of serialized config must succeed");
1843 let toml2 = toml::to_string_pretty(&parsed)
1844 .expect("second serialize must succeed");
1845 prop_assert_eq!(toml1, toml2);
1846 }
1847
1848 #[test]
1850 fn default_config_always_validates(_seed in any::<u64>()) {
1851 let config = ShipperConfig::default();
1852 prop_assert!(config.validate().is_ok());
1853 }
1854
1855 #[test]
1857 fn generated_valid_config_passes_validation(config in arb_valid_config()) {
1858 prop_assert!(config.validate().is_ok());
1859 }
1860
1861 #[test]
1863 fn valid_config_serializes_to_valid_toml(config in arb_valid_config()) {
1864 let toml_str = toml::to_string_pretty(&config)
1865 .expect("serialize must succeed");
1866 let reparsed: Result<ShipperConfig, _> = toml::from_str(&toml_str);
1867 prop_assert!(reparsed.is_ok(), "re-parse failed: {:?}", reparsed.err());
1868 }
1869
1870 #[test]
1873 fn merge_with_empty_overrides_preserves_config(config in arb_valid_config()) {
1874 let cli = CliOverrides::default();
1875 let opts = config.build_runtime_options(cli);
1876
1877 prop_assert_eq!(opts.allow_dirty, config.flags.allow_dirty);
1878 prop_assert_eq!(opts.skip_ownership_check, config.flags.skip_ownership_check);
1879 prop_assert_eq!(opts.strict_ownership, config.flags.strict_ownership);
1880 prop_assert_eq!(opts.output_lines, config.output.lines);
1881 prop_assert_eq!(opts.lock_timeout, config.lock.timeout);
1882 prop_assert_eq!(opts.policy, config.policy.mode);
1883 prop_assert_eq!(opts.verify_mode, config.verify.mode);
1884 prop_assert_eq!(opts.readiness.enabled, config.readiness.enabled);
1885 prop_assert_eq!(opts.readiness.method, config.readiness.method);
1886 prop_assert_eq!(opts.parallel.enabled, config.parallel.enabled);
1887 prop_assert_eq!(opts.parallel.max_concurrent, config.parallel.max_concurrent);
1888 prop_assert_eq!(
1889 opts.parallel.per_package_timeout,
1890 config.parallel.per_package_timeout
1891 );
1892 }
1893 }
1894 }
1895
1896 mod edge_cases {
1899 use super::*;
1900
1901 #[test]
1903 fn empty_toml_parses_to_defaults() {
1904 let config: ShipperConfig = toml::from_str("").unwrap();
1905 assert_eq!(config.policy.mode, PublishPolicy::Safe);
1906 assert_eq!(config.verify.mode, VerifyMode::Workspace);
1907 assert_eq!(config.output.lines, 50);
1908 assert_eq!(config.retry.max_attempts, 6);
1909 assert!(!config.flags.allow_dirty);
1910 assert!(config.validate().is_ok());
1911 }
1912
1913 #[test]
1915 fn unknown_sections_are_ignored() {
1916 let toml = r#"
1917[completely_unknown]
1918foo = "bar"
1919baz = 42
1920
1921[another_unknown]
1922x = true
1923"#;
1924 let config: ShipperConfig = toml::from_str(toml).unwrap();
1925 assert_eq!(config.policy.mode, PublishPolicy::Safe);
1926 assert!(config.validate().is_ok());
1927 }
1928
1929 #[test]
1930 fn unknown_fields_within_known_sections_are_ignored() {
1931 let toml = r#"
1932[policy]
1933mode = "fast"
1934nonexistent_field = "hello"
1935
1936[flags]
1937allow_dirty = true
1938unknown_flag = 999
1939"#;
1940 let config: ShipperConfig = toml::from_str(toml).unwrap();
1941 assert_eq!(config.policy.mode, PublishPolicy::Fast);
1942 assert!(config.flags.allow_dirty);
1943 }
1944
1945 #[test]
1947 fn only_policy_section() {
1948 let toml = r#"
1949[policy]
1950mode = "balanced"
1951"#;
1952 let config: ShipperConfig = toml::from_str(toml).unwrap();
1953 assert_eq!(config.policy.mode, PublishPolicy::Balanced);
1954 assert_eq!(config.verify.mode, VerifyMode::Workspace);
1956 assert_eq!(config.output.lines, 50);
1957 assert!(config.validate().is_ok());
1958 }
1959
1960 #[test]
1961 fn only_verify_section() {
1962 let toml = r#"
1963[verify]
1964mode = "none"
1965"#;
1966 let config: ShipperConfig = toml::from_str(toml).unwrap();
1967 assert_eq!(config.verify.mode, VerifyMode::None);
1968 assert_eq!(config.policy.mode, PublishPolicy::Safe);
1969 assert!(config.validate().is_ok());
1970 }
1971
1972 #[test]
1973 fn only_readiness_section() {
1974 let toml = r#"
1975[readiness]
1976enabled = false
1977method = "index"
1978"#;
1979 let config: ShipperConfig = toml::from_str(toml).unwrap();
1980 assert!(!config.readiness.enabled);
1981 assert_eq!(config.readiness.method, ReadinessMethod::Index);
1982 assert!(config.validate().is_ok());
1983 }
1984
1985 #[test]
1986 fn only_output_section() {
1987 let toml = r#"
1988[output]
1989lines = 999
1990"#;
1991 let config: ShipperConfig = toml::from_str(toml).unwrap();
1992 assert_eq!(config.output.lines, 999);
1993 assert!(config.validate().is_ok());
1994 }
1995
1996 #[test]
1997 fn only_lock_section() {
1998 let toml = r#"
1999[lock]
2000timeout = "10m"
2001"#;
2002 let config: ShipperConfig = toml::from_str(toml).unwrap();
2003 assert_eq!(config.lock.timeout, Duration::from_secs(600));
2004 assert!(config.validate().is_ok());
2005 }
2006
2007 #[test]
2008 fn only_retry_section() {
2009 let toml = r#"
2010[retry]
2011policy = "aggressive"
2012max_attempts = 10
2013base_delay = "500ms"
2014max_delay = "30s"
2015strategy = "linear"
2016jitter = 0.1
2017"#;
2018 let config: ShipperConfig = toml::from_str(toml).unwrap();
2019 assert_eq!(config.retry.policy, RetryPolicy::Aggressive);
2020 assert_eq!(config.retry.max_attempts, 10);
2021 assert_eq!(config.retry.strategy, RetryStrategyType::Linear);
2022 assert!(config.validate().is_ok());
2023 }
2024
2025 #[test]
2026 fn only_flags_section() {
2027 let toml = r#"
2028[flags]
2029allow_dirty = true
2030skip_ownership_check = true
2031strict_ownership = true
2032"#;
2033 let config: ShipperConfig = toml::from_str(toml).unwrap();
2034 assert!(config.flags.allow_dirty);
2035 assert!(config.flags.skip_ownership_check);
2036 assert!(config.flags.strict_ownership);
2037 assert!(config.validate().is_ok());
2038 }
2039
2040 #[test]
2041 fn only_parallel_section() {
2042 let toml = r#"
2043[parallel]
2044enabled = true
2045max_concurrent = 16
2046per_package_timeout = "2h"
2047"#;
2048 let config: ShipperConfig = toml::from_str(toml).unwrap();
2049 assert!(config.parallel.enabled);
2050 assert_eq!(config.parallel.max_concurrent, 16);
2051 assert_eq!(
2052 config.parallel.per_package_timeout,
2053 Duration::from_secs(7200)
2054 );
2055 assert!(config.validate().is_ok());
2056 }
2057
2058 #[test]
2059 fn only_registry_section() {
2060 let toml = r#"
2061[registry]
2062name = "my-reg"
2063api_base = "https://example.com"
2064"#;
2065 let config: ShipperConfig = toml::from_str(toml).unwrap();
2066 let reg = config.registry.as_ref().unwrap();
2067 assert_eq!(reg.name, "my-reg");
2068 assert_eq!(reg.api_base, "https://example.com");
2069 assert!(config.validate().is_ok());
2070 }
2071
2072 #[test]
2073 fn only_encryption_section() {
2074 let toml = r#"
2075[encryption]
2076enabled = true
2077passphrase = "secret123"
2078env_key = "MY_KEY"
2079"#;
2080 let config: ShipperConfig = toml::from_str(toml).unwrap();
2081 assert!(config.encryption.enabled);
2082 assert_eq!(config.encryption.passphrase.as_deref(), Some("secret123"));
2083 assert_eq!(config.encryption.env_key.as_deref(), Some("MY_KEY"));
2084 assert!(config.validate().is_ok());
2085 }
2086
2087 #[test]
2088 fn only_storage_section() {
2089 let toml = r#"
2090[storage]
2091storage_type = "S3"
2092bucket = "my-bucket"
2093region = "us-west-2"
2094"#;
2095 let config: ShipperConfig = toml::from_str(toml).unwrap();
2096 assert_eq!(config.storage.storage_type, StorageType::S3);
2097 assert_eq!(config.storage.bucket.as_deref(), Some("my-bucket"));
2098 assert!(config.storage.is_configured());
2099 assert!(config.validate().is_ok());
2100 }
2101
2102 #[test]
2104 fn retry_base_delay_exceeds_max_delay_fails_validation() {
2105 let toml = r#"
2106[retry]
2107max_attempts = 3
2108base_delay = "10s"
2109max_delay = "5s"
2110"#;
2111 let config: ShipperConfig = toml::from_str(toml).unwrap();
2112 let err = config.validate().unwrap_err();
2113 assert!(
2114 err.to_string()
2115 .contains("retry.max_delay must be greater than or equal to retry.base_delay"),
2116 "got: {}",
2117 err
2118 );
2119 }
2120
2121 #[test]
2122 fn retry_jitter_above_one_fails_validation() {
2123 let mut config = ShipperConfig::default();
2124 config.retry.jitter = 1.01;
2125 assert!(config.validate().is_err());
2126 }
2127
2128 #[test]
2129 fn retry_jitter_negative_fails_validation() {
2130 let mut config = ShipperConfig::default();
2131 config.retry.jitter = -0.001;
2132 assert!(config.validate().is_err());
2133 }
2134
2135 #[test]
2136 fn readiness_jitter_factor_above_one_fails_validation() {
2137 let mut config = ShipperConfig::default();
2138 config.readiness.jitter_factor = 1.001;
2139 assert!(config.validate().is_err());
2140 }
2141
2142 #[test]
2143 fn multiple_default_registries_fails_validation() {
2144 let config = ShipperConfig {
2145 registries: MultiRegistryConfig {
2146 registries: vec![
2147 RegistryConfig {
2148 name: "reg-a".to_string(),
2149 api_base: "https://a.example.com".to_string(),
2150 index_base: None,
2151 token: None,
2152 default: true,
2153 },
2154 RegistryConfig {
2155 name: "reg-b".to_string(),
2156 api_base: "https://b.example.com".to_string(),
2157 index_base: None,
2158 token: None,
2159 default: true,
2160 },
2161 ],
2162 default_registries: vec![],
2163 },
2164 ..ShipperConfig::default()
2165 };
2166 let err = config.validate().unwrap_err();
2167 assert!(
2168 err.to_string().contains("only one registry"),
2169 "got: {}",
2170 err
2171 );
2172 }
2173
2174 #[test]
2175 fn registries_with_empty_name_fails_validation() {
2176 let config = ShipperConfig {
2177 registries: MultiRegistryConfig {
2178 registries: vec![RegistryConfig {
2179 name: String::new(),
2180 api_base: "https://example.com".to_string(),
2181 index_base: None,
2182 token: None,
2183 default: false,
2184 }],
2185 default_registries: vec![],
2186 },
2187 ..ShipperConfig::default()
2188 };
2189 assert!(config.validate().is_err());
2190 }
2191
2192 #[test]
2193 fn registries_with_empty_api_base_fails_validation() {
2194 let config = ShipperConfig {
2195 registries: MultiRegistryConfig {
2196 registries: vec![RegistryConfig {
2197 name: "my-reg".to_string(),
2198 api_base: String::new(),
2199 index_base: None,
2200 token: None,
2201 default: false,
2202 }],
2203 default_registries: vec![],
2204 },
2205 ..ShipperConfig::default()
2206 };
2207 assert!(config.validate().is_err());
2208 }
2209
2210 #[test]
2211 fn parallel_zero_max_concurrent_fails_validation() {
2212 let mut config = ShipperConfig::default();
2213 config.parallel.max_concurrent = 0;
2214 assert!(config.validate().is_err());
2215 }
2216
2217 #[test]
2218 fn parallel_zero_per_package_timeout_fails_validation() {
2219 let mut config = ShipperConfig::default();
2220 config.parallel.per_package_timeout = Duration::ZERO;
2221 assert!(config.validate().is_err());
2222 }
2223
2224 #[test]
2225 fn readiness_zero_max_total_wait_fails_validation() {
2226 let mut config = ShipperConfig::default();
2227 config.readiness.max_total_wait = Duration::ZERO;
2228 assert!(config.validate().is_err());
2229 }
2230
2231 #[test]
2232 fn readiness_zero_poll_interval_fails_validation() {
2233 let mut config = ShipperConfig::default();
2234 config.readiness.poll_interval = Duration::ZERO;
2235 assert!(config.validate().is_err());
2236 }
2237
2238 #[test]
2240 fn very_long_state_dir_path() {
2241 let long_path = "a".repeat(12_000);
2242 let toml = format!("state_dir = \"{}\"", long_path);
2243 let config: ShipperConfig = toml::from_str(&toml).unwrap();
2244 assert_eq!(
2245 config.state_dir.as_ref().unwrap().to_str().unwrap().len(),
2246 12_000
2247 );
2248 assert!(config.validate().is_ok());
2249 }
2250
2251 #[test]
2252 fn very_long_registry_name() {
2253 let long_name = "r".repeat(11_000);
2254 let toml = format!(
2255 "[registry]\nname = \"{}\"\napi_base = \"https://example.com\"",
2256 long_name
2257 );
2258 let config: ShipperConfig = toml::from_str(&toml).unwrap();
2259 assert_eq!(config.registry.as_ref().unwrap().name.len(), 11_000);
2260 assert!(config.validate().is_ok());
2261 }
2262
2263 #[test]
2264 fn very_long_api_base_url() {
2265 let long_url = format!("https://example.com/{}", "x".repeat(11_000));
2266 let toml = format!("[registry]\nname = \"reg\"\napi_base = \"{}\"", long_url);
2267 let config: ShipperConfig = toml::from_str(&toml).unwrap();
2268 assert!(config.validate().is_ok());
2269 }
2270
2271 #[test]
2272 fn very_long_encryption_passphrase() {
2273 let long_pass = "p".repeat(15_000);
2274 let toml = format!(
2275 "[encryption]\nenabled = true\npassphrase = \"{}\"",
2276 long_pass
2277 );
2278 let config: ShipperConfig = toml::from_str(&toml).unwrap();
2279 assert_eq!(config.encryption.passphrase.as_ref().unwrap().len(), 15_000);
2280 }
2281
2282 #[test]
2283 fn very_long_storage_bucket() {
2284 let long_bucket = "b".repeat(10_500);
2285 let toml = format!(
2286 "[storage]\nstorage_type = \"S3\"\nbucket = \"{}\"",
2287 long_bucket
2288 );
2289 let config: ShipperConfig = toml::from_str(&toml).unwrap();
2290 assert_eq!(config.storage.bucket.as_ref().unwrap().len(), 10_500);
2291 }
2292
2293 #[test]
2295 fn unicode_state_dir() {
2296 let toml = r#"state_dir = "日本語/パス/🚀""#;
2297 let config: ShipperConfig = toml::from_str(toml).unwrap();
2298 assert_eq!(
2299 config.state_dir.as_ref().unwrap(),
2300 &PathBuf::from("日本語/パス/🚀")
2301 );
2302 assert!(config.validate().is_ok());
2303 }
2304
2305 #[test]
2306 fn unicode_registry_name() {
2307 let toml = r#"
2308[registry]
2309name = "登録-ré̀gistry-🦀"
2310api_base = "https://例え.jp/api"
2311"#;
2312 let config: ShipperConfig = toml::from_str(toml).unwrap();
2313 let reg = config.registry.as_ref().unwrap();
2314 assert_eq!(reg.name, "登録-ré̀gistry-🦀");
2315 assert_eq!(reg.api_base, "https://例え.jp/api");
2316 assert!(config.validate().is_ok());
2317 }
2318
2319 #[test]
2320 fn unicode_encryption_passphrase() {
2321 let toml = r#"
2322[encryption]
2323enabled = true
2324passphrase = "密码🔑пароль"
2325env_key = "环境变量_KEY"
2326"#;
2327 let config: ShipperConfig = toml::from_str(toml).unwrap();
2328 assert_eq!(
2329 config.encryption.passphrase.as_deref(),
2330 Some("密码🔑пароль")
2331 );
2332 assert_eq!(config.encryption.env_key.as_deref(), Some("环境变量_KEY"));
2333 }
2334
2335 #[test]
2336 fn unicode_storage_base_path() {
2337 let toml = r#"
2338[storage]
2339storage_type = "Gcs"
2340bucket = "バケット"
2341base_path = "リリース/ストレージ/"
2342"#;
2343 let config: ShipperConfig = toml::from_str(toml).unwrap();
2344 assert_eq!(config.storage.bucket.as_deref(), Some("バケット"));
2345 assert_eq!(
2346 config.storage.base_path.as_deref(),
2347 Some("リリース/ストレージ/")
2348 );
2349 }
2350
2351 #[test]
2353 fn policy_preset_safe() {
2354 let toml = r#"
2355[policy]
2356mode = "safe"
2357"#;
2358 let config: ShipperConfig = toml::from_str(toml).unwrap();
2359 assert_eq!(config.policy.mode, PublishPolicy::Safe);
2360 assert!(config.validate().is_ok());
2361 }
2362
2363 #[test]
2364 fn policy_preset_balanced() {
2365 let toml = r#"
2366[policy]
2367mode = "balanced"
2368"#;
2369 let config: ShipperConfig = toml::from_str(toml).unwrap();
2370 assert_eq!(config.policy.mode, PublishPolicy::Balanced);
2371 assert!(config.validate().is_ok());
2372 }
2373
2374 #[test]
2375 fn policy_preset_fast() {
2376 let toml = r#"
2377[policy]
2378mode = "fast"
2379"#;
2380 let config: ShipperConfig = toml::from_str(toml).unwrap();
2381 assert_eq!(config.policy.mode, PublishPolicy::Fast);
2382 assert!(config.validate().is_ok());
2383 }
2384
2385 #[test]
2386 fn policy_preset_invalid_is_rejected() {
2387 let toml = r#"
2388[policy]
2389mode = "turbo"
2390"#;
2391 let result: Result<ShipperConfig, _> = toml::from_str(toml);
2392 assert!(result.is_err());
2393 }
2394
2395 #[test]
2396 fn policy_presets_runtime_options_safe() {
2397 let config = ShipperConfig {
2398 policy: PolicyConfig {
2399 mode: PublishPolicy::Safe,
2400 },
2401 ..ShipperConfig::default()
2402 };
2403 let opts = config.build_runtime_options(CliOverrides::default());
2404 assert_eq!(opts.policy, PublishPolicy::Safe);
2405 }
2406
2407 #[test]
2408 fn policy_presets_runtime_options_balanced() {
2409 let config = ShipperConfig {
2410 policy: PolicyConfig {
2411 mode: PublishPolicy::Balanced,
2412 },
2413 ..ShipperConfig::default()
2414 };
2415 let opts = config.build_runtime_options(CliOverrides::default());
2416 assert_eq!(opts.policy, PublishPolicy::Balanced);
2417 }
2418
2419 #[test]
2420 fn policy_presets_runtime_options_fast() {
2421 let config = ShipperConfig {
2422 policy: PolicyConfig {
2423 mode: PublishPolicy::Fast,
2424 },
2425 ..ShipperConfig::default()
2426 };
2427 let opts = config.build_runtime_options(CliOverrides::default());
2428 assert_eq!(opts.policy, PublishPolicy::Fast);
2429 }
2430
2431 #[test]
2433 fn retry_policy_preset_default() {
2434 let toml = "[retry]\npolicy = \"default\"";
2435 let config: ShipperConfig = toml::from_str(toml).unwrap();
2436 assert_eq!(config.retry.policy, RetryPolicy::Default);
2437 }
2438
2439 #[test]
2440 fn retry_policy_preset_aggressive() {
2441 let toml = "[retry]\npolicy = \"aggressive\"";
2442 let config: ShipperConfig = toml::from_str(toml).unwrap();
2443 assert_eq!(config.retry.policy, RetryPolicy::Aggressive);
2444 }
2445
2446 #[test]
2447 fn retry_policy_preset_conservative() {
2448 let toml = "[retry]\npolicy = \"conservative\"";
2449 let config: ShipperConfig = toml::from_str(toml).unwrap();
2450 assert_eq!(config.retry.policy, RetryPolicy::Conservative);
2451 }
2452
2453 #[test]
2454 fn retry_policy_preset_custom() {
2455 let toml = "[retry]\npolicy = \"custom\"";
2456 let config: ShipperConfig = toml::from_str(toml).unwrap();
2457 assert_eq!(config.retry.policy, RetryPolicy::Custom);
2458 }
2459
2460 #[test]
2462 fn multi_registry_get_registries_default_when_empty() {
2463 let cfg = MultiRegistryConfig::default();
2464 let regs = cfg.get_registries();
2465 assert_eq!(regs.len(), 1);
2466 assert_eq!(regs[0].name, "crates-io");
2467 assert!(regs[0].default);
2468 }
2469
2470 #[test]
2471 fn multi_registry_get_default_uses_first_default() {
2472 let cfg = MultiRegistryConfig {
2473 registries: vec![
2474 RegistryConfig {
2475 name: "first".to_string(),
2476 api_base: "https://first.example.com".to_string(),
2477 index_base: None,
2478 token: None,
2479 default: false,
2480 },
2481 RegistryConfig {
2482 name: "second".to_string(),
2483 api_base: "https://second.example.com".to_string(),
2484 index_base: None,
2485 token: None,
2486 default: true,
2487 },
2488 ],
2489 default_registries: vec![],
2490 };
2491 let default = cfg.get_default();
2492 assert_eq!(default.name, "second");
2493 }
2494
2495 #[test]
2496 fn multi_registry_find_by_name_returns_none_for_missing() {
2497 let cfg = MultiRegistryConfig {
2498 registries: vec![RegistryConfig {
2499 name: "exists".to_string(),
2500 api_base: "https://exists.example.com".to_string(),
2501 index_base: None,
2502 token: None,
2503 default: false,
2504 }],
2505 default_registries: vec![],
2506 };
2507 assert!(cfg.find_by_name("nonexistent").is_none());
2508 assert!(cfg.find_by_name("exists").is_some());
2509 }
2510
2511 #[test]
2513 fn storage_not_configured_without_bucket() {
2514 let storage = StorageConfigInner {
2515 storage_type: StorageType::S3,
2516 bucket: None,
2517 ..Default::default()
2518 };
2519 assert!(!storage.is_configured());
2520 assert!(storage.to_cloud_config().is_none());
2521 }
2522
2523 #[test]
2524 fn storage_not_configured_with_file_type() {
2525 let storage = StorageConfigInner {
2526 storage_type: StorageType::File,
2527 bucket: Some("bucket".to_string()),
2528 ..Default::default()
2529 };
2530 assert!(!storage.is_configured());
2531 }
2532
2533 #[test]
2534 fn storage_configured_with_bucket_and_non_file_type() {
2535 let storage = StorageConfigInner {
2536 storage_type: StorageType::S3,
2537 bucket: Some("bucket".to_string()),
2538 region: Some("us-east-1".to_string()),
2539 ..Default::default()
2540 };
2541 assert!(storage.is_configured());
2542 let cloud = storage.to_cloud_config().unwrap();
2543 assert_eq!(cloud.bucket, "bucket");
2544 assert_eq!(cloud.region, Some("us-east-1".to_string()));
2545 }
2546
2547 #[test]
2549 fn invalid_schema_version_fails_load() {
2550 let td = tempfile::tempdir().unwrap();
2551 let path = td.path().join("test.toml");
2552 std::fs::write(&path, "schema_version = \"not.a.valid.schema\"").unwrap();
2553 let result = ShipperConfig::load_from_file(&path);
2554 assert!(result.is_err());
2555 }
2556
2557 #[test]
2558 fn default_schema_version_is_v1() {
2559 let config = ShipperConfig::default();
2560 assert_eq!(config.schema_version, "shipper.config.v1");
2561 }
2562
2563 #[test]
2565 fn load_from_workspace_returns_none_when_no_config() {
2566 let td = tempfile::tempdir().unwrap();
2567 let result = ShipperConfig::load_from_workspace(td.path()).unwrap();
2568 assert!(result.is_none());
2569 }
2570
2571 #[test]
2572 fn load_from_workspace_finds_config() {
2573 let td = tempfile::tempdir().unwrap();
2574 let path = td.path().join(".shipper.toml");
2575 std::fs::write(&path, "").unwrap();
2576 let result = ShipperConfig::load_from_workspace(td.path()).unwrap();
2577 assert!(result.is_some());
2578 }
2579
2580 #[test]
2582 fn output_lines_max_value() {
2583 let toml = "[output]\nlines = 4294967295";
2584 let config: ShipperConfig = toml::from_str(toml).unwrap();
2585 assert_eq!(config.output.lines, 4_294_967_295);
2586 assert!(config.validate().is_ok());
2587 }
2588
2589 #[test]
2590 fn retry_max_attempts_one_is_valid() {
2591 let mut config = ShipperConfig::default();
2592 config.retry.max_attempts = 1;
2593 assert!(config.validate().is_ok());
2594 }
2595
2596 #[test]
2597 fn retry_jitter_boundary_zero() {
2598 let mut config = ShipperConfig::default();
2599 config.retry.jitter = 0.0;
2600 assert!(config.validate().is_ok());
2601 }
2602
2603 #[test]
2604 fn retry_jitter_boundary_one() {
2605 let mut config = ShipperConfig::default();
2606 config.retry.jitter = 1.0;
2607 assert!(config.validate().is_ok());
2608 }
2609
2610 #[test]
2611 fn readiness_jitter_factor_boundary_zero() {
2612 let mut config = ShipperConfig::default();
2613 config.readiness.jitter_factor = 0.0;
2614 assert!(config.validate().is_ok());
2615 }
2616
2617 #[test]
2618 fn readiness_jitter_factor_boundary_one() {
2619 let mut config = ShipperConfig::default();
2620 config.readiness.jitter_factor = 1.0;
2621 assert!(config.validate().is_ok());
2622 }
2623
2624 #[test]
2626 fn encryption_cli_overrides_config_passphrase() {
2627 let config = ShipperConfig {
2628 encryption: EncryptionConfigInner {
2629 enabled: true,
2630 passphrase: Some("config-pass".to_string()),
2631 env_key: None,
2632 },
2633 ..ShipperConfig::default()
2634 };
2635 let cli = CliOverrides {
2636 encrypt: true,
2637 encrypt_passphrase: Some("cli-pass".to_string()),
2638 ..Default::default()
2639 };
2640 let opts = config.build_runtime_options(cli);
2641 assert!(opts.encryption.enabled);
2642 assert_eq!(opts.encryption.passphrase.as_deref(), Some("cli-pass"));
2643 }
2644
2645 #[test]
2646 fn encryption_enabled_without_passphrase_uses_default_env_var() {
2647 let config = ShipperConfig {
2648 encryption: EncryptionConfigInner {
2649 enabled: true,
2650 passphrase: None,
2651 env_key: None,
2652 },
2653 ..ShipperConfig::default()
2654 };
2655 let opts = config.build_runtime_options(CliOverrides::default());
2656 assert!(opts.encryption.enabled);
2657 assert_eq!(
2658 opts.encryption.env_var.as_deref(),
2659 Some("SHIPPER_ENCRYPT_KEY")
2660 );
2661 }
2662 }
2663
2664 mod edge_case_snapshots {
2667 use super::*;
2668
2669 #[test]
2670 fn snapshot_default_shipper_config_debug() {
2671 let config = ShipperConfig::default();
2672 insta::assert_debug_snapshot!("edge_default_config_debug", config);
2673 }
2674
2675 #[test]
2676 fn snapshot_policy_preset_safe_config() {
2677 let config = ShipperConfig {
2678 policy: PolicyConfig {
2679 mode: PublishPolicy::Safe,
2680 },
2681 ..ShipperConfig::default()
2682 };
2683 let opts = config.build_runtime_options(CliOverrides::default());
2684 insta::assert_debug_snapshot!("edge_policy_safe_runtime", opts);
2685 }
2686
2687 #[test]
2688 fn snapshot_policy_preset_balanced_config() {
2689 let config = ShipperConfig {
2690 policy: PolicyConfig {
2691 mode: PublishPolicy::Balanced,
2692 },
2693 ..ShipperConfig::default()
2694 };
2695 let opts = config.build_runtime_options(CliOverrides::default());
2696 insta::assert_debug_snapshot!("edge_policy_balanced_runtime", opts);
2697 }
2698
2699 #[test]
2700 fn snapshot_policy_preset_fast_config() {
2701 let config = ShipperConfig {
2702 policy: PolicyConfig {
2703 mode: PublishPolicy::Fast,
2704 },
2705 ..ShipperConfig::default()
2706 };
2707 let opts = config.build_runtime_options(CliOverrides::default());
2708 insta::assert_debug_snapshot!("edge_policy_fast_runtime", opts);
2709 }
2710
2711 #[test]
2712 fn snapshot_empty_toml_parsed() {
2713 let config: ShipperConfig = toml::from_str("").unwrap();
2714 insta::assert_debug_snapshot!("edge_empty_toml_parsed", config);
2715 }
2716 }
2717
2718 mod edge_case_proptests {
2721 use super::*;
2722 use proptest::prelude::*;
2723
2724 proptest! {
2725 #[test]
2727 fn serialize_then_deserialize_roundtrip(
2728 policy in prop_oneof![
2729 Just(PublishPolicy::Safe),
2730 Just(PublishPolicy::Balanced),
2731 Just(PublishPolicy::Fast),
2732 ],
2733 verify in prop_oneof![
2734 Just(VerifyMode::Workspace),
2735 Just(VerifyMode::Package),
2736 Just(VerifyMode::None),
2737 ],
2738 output_lines in 1usize..1000,
2739 max_attempts in 1u32..100,
2740 base_delay_secs in 1u64..100,
2741 extra_delay_secs in 0u64..500,
2742 jitter in 0.0f64..=1.0,
2743 allow_dirty in any::<bool>(),
2744 ) {
2745 let config = ShipperConfig {
2746 schema_version: default_schema_version(),
2747 policy: PolicyConfig { mode: policy },
2748 verify: VerifyConfig { mode: verify },
2749 output: OutputConfig { lines: output_lines },
2750 retry: RetryConfig {
2751 policy: RetryPolicy::Custom,
2752 max_attempts,
2753 base_delay: Duration::from_secs(base_delay_secs),
2754 max_delay: Duration::from_secs(base_delay_secs + extra_delay_secs),
2755 strategy: RetryStrategyType::Exponential,
2756 jitter,
2757 per_error: PerErrorConfig::default(),
2758 },
2759 flags: FlagsConfig {
2760 allow_dirty,
2761 ..Default::default()
2762 },
2763 ..ShipperConfig::default()
2764 };
2765
2766 let serialized = toml::to_string_pretty(&config)
2767 .expect("serialize must succeed");
2768 let deserialized: ShipperConfig = toml::from_str(&serialized)
2769 .expect("deserialize must succeed");
2770 let re_serialized = toml::to_string_pretty(&deserialized)
2771 .expect("re-serialize must succeed");
2772
2773 prop_assert_eq!(&serialized, &re_serialized);
2774 prop_assert_eq!(deserialized.policy.mode, policy);
2775 prop_assert_eq!(deserialized.verify.mode, verify);
2776 prop_assert_eq!(deserialized.output.lines, output_lines);
2777 prop_assert_eq!(deserialized.retry.max_attempts, max_attempts);
2778 prop_assert_eq!(deserialized.flags.allow_dirty, allow_dirty);
2779 }
2780 }
2781 }
2782}
2783
2784#[cfg(test)]
2785mod config_parsing_edge_case_tests {
2786 use super::*;
2787 use std::io::Write;
2788 use tempfile::tempdir;
2789
2790 #[test]
2793 fn load_toml_with_utf8_bom() {
2794 let td = tempdir().expect("tempdir");
2795 let config_path = td.path().join(".shipper.toml");
2796 let mut f = std::fs::File::create(&config_path).expect("create");
2797 f.write_all(b"\xEF\xBB\xBF").expect("write bom");
2799 f.write_all(b"schema_version = \"shipper.config.v1\"\n")
2800 .expect("write");
2801 drop(f);
2802
2803 let result = ShipperConfig::load_from_file(&config_path);
2805 if let Err(e) = &result {
2808 assert!(
2809 e.to_string().contains("parse") || e.to_string().contains("unexpected"),
2810 "error should mention parsing: {}",
2811 e
2812 );
2813 }
2814 }
2815
2816 #[test]
2819 fn load_toml_with_trailing_whitespace() {
2820 let td = tempdir().expect("tempdir");
2821 let config_path = td.path().join(".shipper.toml");
2822 let content = "schema_version = \"shipper.config.v1\" \n\
2823 [policy] \n\
2824 mode = \"safe\" \n";
2825 std::fs::write(&config_path, content).expect("write");
2826
2827 let config = ShipperConfig::load_from_file(&config_path).expect("parse");
2828 assert_eq!(config.schema_version, "shipper.config.v1");
2829 }
2830
2831 #[test]
2834 fn load_empty_toml_uses_defaults() {
2835 let td = tempdir().expect("tempdir");
2836 let config_path = td.path().join(".shipper.toml");
2837 std::fs::write(&config_path, "").expect("write");
2838
2839 let config = ShipperConfig::load_from_file(&config_path).expect("parse");
2840 assert_eq!(config.schema_version, "shipper.config.v1");
2841 assert_eq!(config.output.lines, 50);
2842 }
2843
2844 #[test]
2847 fn load_toml_with_unknown_keys() {
2848 let td = tempdir().expect("tempdir");
2849 let config_path = td.path().join(".shipper.toml");
2850 let content = r#"
2851 schema_version = "shipper.config.v1"
2852 unknown_top_level_key = "should be ignored or error"
2853 "#;
2854 std::fs::write(&config_path, content).expect("write");
2855
2856 let result = ShipperConfig::load_from_file(&config_path);
2857 let _ = result;
2859 }
2860
2861 #[test]
2864 fn load_from_workspace_returns_none_without_config() {
2865 let td = tempdir().expect("tempdir");
2866 let result = ShipperConfig::load_from_workspace(td.path()).expect("load");
2867 assert!(result.is_none());
2868 }
2869
2870 #[test]
2873 fn load_toml_whitespace_only() {
2874 let td = tempdir().expect("tempdir");
2875 let config_path = td.path().join(".shipper.toml");
2876 std::fs::write(&config_path, " \n \n\t\n").expect("write");
2877
2878 let config = ShipperConfig::load_from_file(&config_path).expect("parse");
2879 assert_eq!(config.schema_version, "shipper.config.v1");
2880 }
2881
2882 #[test]
2885 fn load_toml_with_crlf_line_endings() {
2886 let td = tempdir().expect("tempdir");
2887 let config_path = td.path().join(".shipper.toml");
2888 let content = "schema_version = \"shipper.config.v1\"\r\n[policy]\r\nmode = \"fast\"\r\n";
2889 std::fs::write(&config_path, content).expect("write");
2890
2891 let config = ShipperConfig::load_from_file(&config_path).expect("parse");
2892 assert_eq!(config.schema_version, "shipper.config.v1");
2893 }
2894}