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