Skip to main content

shipper_config/
lib.rs

1//! # Configuration
2//!
3//! Project-specific configuration for Shipper via `.shipper.toml`.
4//!
5//! This crate loads, validates, and merges configuration from three layers
6//! (highest priority first):
7//!
8//! 1. **CLI flags** — passed via [`CliOverrides`]
9//! 2. **Config file** — `.shipper.toml` in the workspace root
10//! 3. **Built-in defaults** — sensible defaults for all settings
11//!
12//! The central type is [`ShipperConfig`], which maps 1:1 to the TOML file
13//! and exposes [`ShipperConfig::build_runtime_options`] to produce the
14//! final [`RuntimeOptions`] used by the engine.
15//!
16//! ## Sections
17//!
18//! | TOML section    | Rust type              | Controls                              |
19//! |-----------------|------------------------|---------------------------------------|
20//! | `[policy]`      | [`PolicyConfig`]       | Safety vs speed preset                |
21//! | `[verify]`      | [`VerifyConfig`]       | Pre-publish compilation check         |
22//! | `[readiness]`   | [`ReadinessConfig`]    | Post-publish visibility polling       |
23//! | `[output]`      | [`OutputConfig`]       | Evidence capture line count           |
24//! | `[lock]`        | [`LockConfig`]         | Distributed lock timeout              |
25//! | `[retry]`       | [`RetryConfig`]        | Retry strategy and backoff            |
26//! | `[flags]`       | [`FlagsConfig`]        | Git-dirty, ownership, etc.            |
27//! | `[parallel]`    | [`ParallelConfig`]     | Concurrent publishing                 |
28//! | `[registry]`    | [`RegistryConfig`]     | Custom registry                       |
29//! | `[registries]`  | [`MultiRegistryConfig`]| Multi-registry publishing             |
30//! | `[webhook]`     | [`WebhookConfig`]      | Publish notifications                 |
31//! | `[encryption]`  | [`EncryptionConfigInner`] | State file encryption              |
32//! | `[storage]`     | [`StorageConfigInner`] | Cloud storage backend                 |
33
34use 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
52/// Runtime-options conversion helpers (previously `shipper-config-runtime`).
53pub mod runtime;
54
55/// Nested policy configuration
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct PolicyConfig {
58    /// Publishing policy: safe, balanced, or fast
59    #[serde(default)]
60    pub mode: PublishPolicy,
61}
62
63/// Nested verify configuration
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct VerifyConfig {
66    /// Verify mode: workspace, package, or none
67    #[serde(default)]
68    pub mode: VerifyMode,
69}
70
71/// Nested retry configuration
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RetryConfig {
74    /// Retry policy preset: default, aggressive, conservative, or custom
75    #[serde(default)]
76    pub policy: RetryPolicy,
77
78    /// Max attempts per crate publish step (used when policy is custom or as fallback)
79    #[serde(default = "default_max_attempts")]
80    pub max_attempts: u32,
81
82    /// Base backoff delay
83    #[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    /// Max backoff delay
91    #[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    /// Strategy type: immediate, exponential, linear, constant
99    #[serde(default)]
100    pub strategy: RetryStrategyType,
101
102    /// Jitter factor for randomized delays (0.0 = no jitter, 1.0 = full jitter)
103    #[serde(default = "default_jitter")]
104    pub jitter: f64,
105
106    /// Per-error-type retry configuration
107    #[serde(default)]
108    pub per_error: PerErrorConfig,
109}
110
111/// Nested output configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct OutputConfig {
114    /// Number of output lines to capture for evidence
115    #[serde(default = "default_output_lines")]
116    pub lines: usize,
117}
118
119/// Nested lock configuration
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LockConfig {
122    /// Lock timeout duration
123    #[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/// Nested encryption configuration
166#[derive(Debug, Clone, Serialize, Deserialize, Default)]
167pub struct EncryptionConfigInner {
168    /// Enable encryption for state files
169    #[serde(default)]
170    pub enabled: bool,
171    /// Passphrase for encryption/decryption (can also be set via SHIPPER_ENCRYPT_KEY env var)
172    #[serde(default)]
173    pub passphrase: Option<String>,
174    /// Environment variable to read passphrase from (default: SHIPPER_ENCRYPT_KEY)
175    #[serde(default)]
176    pub env_key: Option<String>,
177}
178
179/// Nested storage configuration for cloud storage backends
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct StorageConfigInner {
182    /// Storage type: file, s3, gcs, or azure
183    #[serde(default)]
184    pub storage_type: StorageType,
185    /// Bucket/container name
186    #[serde(default)]
187    pub bucket: Option<String>,
188    /// Region (for S3) or project ID (for GCS)
189    #[serde(default)]
190    pub region: Option<String>,
191    /// Base path within the bucket
192    #[serde(default)]
193    pub base_path: Option<String>,
194    /// Custom endpoint for S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
195    #[serde(default)]
196    pub endpoint: Option<String>,
197    /// Access key ID
198    #[serde(default)]
199    pub access_key_id: Option<String>,
200    /// Secret access key
201    #[serde(default)]
202    pub secret_access_key: Option<String>,
203}
204
205impl StorageConfigInner {
206    /// Build CloudStorageConfig from this configuration
207    ///
208    /// Returns None if storage is not configured (i.e., using local file storage)
209    pub fn to_cloud_config(&self) -> Option<CloudStorageConfig> {
210        // Only build cloud config if bucket is specified
211        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        // Check for environment variable overrides
232        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    /// Check if cloud storage is configured
249    pub fn is_configured(&self) -> bool {
250        self.bucket.is_some() && self.storage_type != StorageType::File
251    }
252}
253
254/// Nested flags configuration
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
256pub struct FlagsConfig {
257    /// Allow publishing from a dirty git working tree
258    #[serde(default)]
259    pub allow_dirty: bool,
260
261    /// Skip owners/permissions preflight
262    #[serde(default)]
263    pub skip_ownership_check: bool,
264
265    /// Fail preflight if ownership checks fail
266    #[serde(default)]
267    pub strict_ownership: bool,
268}
269
270/// Project-specific configuration loaded from `.shipper.toml`.
271///
272/// This is the root deserialization target for the config file.  Each
273/// field corresponds to a TOML section (e.g. `[retry]` → [`RetryConfig`]).
274///
275/// Use [`ShipperConfig::load_from_workspace`] to discover and parse the
276/// file, then [`ShipperConfig::build_runtime_options`] to merge CLI
277/// overrides and produce the final [`RuntimeOptions`].
278#[serde_as]
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ShipperConfig {
281    /// Schema version for the configuration file (e.g., `shipper.config.v1`)
282    #[serde(default = "default_schema_version")]
283    pub schema_version: String,
284
285    /// Publish policy configuration
286    #[serde(default)]
287    pub policy: PolicyConfig,
288
289    /// Verify mode configuration
290    #[serde(default)]
291    pub verify: VerifyConfig,
292
293    /// Readiness check configuration
294    #[serde(default)]
295    pub readiness: ReadinessConfig,
296
297    /// Output configuration
298    #[serde(default)]
299    pub output: OutputConfig,
300
301    /// Lock configuration
302    #[serde(default)]
303    pub lock: LockConfig,
304
305    /// Retry configuration
306    #[serde(default)]
307    pub retry: RetryConfig,
308
309    /// Flags configuration
310    #[serde(default)]
311    pub flags: FlagsConfig,
312
313    /// Parallel publishing configuration
314    #[serde(default)]
315    pub parallel: ParallelConfig,
316
317    /// Optional custom state directory
318    #[serde(default)]
319    pub state_dir: Option<PathBuf>,
320
321    /// Optional custom registry configuration (single registry)
322    #[serde(default)]
323    pub registry: Option<RegistryConfig>,
324
325    /// Multiple registry configuration for multi-registry publishing
326    #[serde(default)]
327    pub registries: MultiRegistryConfig,
328
329    /// Webhook configuration for publish notifications
330    #[serde(default)]
331    pub webhook: WebhookConfig,
332
333    /// Encryption configuration for state files
334    #[serde(default)]
335    pub encryption: EncryptionConfigInner,
336
337    /// Storage configuration for cloud storage backends
338    #[serde(default)]
339    pub storage: StorageConfigInner,
340
341    /// Rehearsal registry configuration — opt-in phase-2 proof before live
342    /// dispatch. See [issue #97](https://github.com/EffortlessMetrics/shipper/issues/97).
343    ///
344    /// This field parses the `[rehearsal]` TOML section. It is wired through
345    /// to CLI overrides but not yet consumed by the engine — follow-on PRs
346    /// under #97 add the phase-2 execution and the gate that refuses live
347    /// dispatch unless rehearsal succeeded for the same plan_id.
348    #[serde(default)]
349    pub rehearsal: RehearsalConfig,
350}
351
352/// Rehearsal registry configuration.
353///
354/// When enabled, Shipper will (in a future PR under [#97](https://github.com/EffortlessMetrics/shipper/issues/97))
355/// run phase-2 proof before live dispatch: publish packaged artifacts to
356/// the named alternate registry, run install/smoke checks, and only then
357/// allow the live `cargo publish` to crates.io (or the target registry).
358///
359/// # Example `.shipper.toml`
360///
361/// ```toml
362/// [rehearsal]
363/// enabled = true
364/// registry = "kellnr-local"  # name must match an entry in [[registries]]
365/// ```
366#[derive(Debug, Clone, Serialize, Deserialize, Default)]
367pub struct RehearsalConfig {
368    /// If `true`, rehearsal runs before live dispatch. Default `false`
369    /// (opt-in until the phase-2 execution PR lands).
370    #[serde(default)]
371    pub enabled: bool,
372
373    /// Name of the registry (declared under `[[registries]]`) to use for
374    /// rehearsal. Must differ from the live target registry.
375    #[serde(default)]
376    pub registry: Option<String>,
377}
378
379/// Registry configuration - supports both single registry and multiple registries
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct RegistryConfig {
382    /// Cargo registry name (e.g., crates-io)
383    pub name: String,
384
385    /// Base URL for registry web API (e.g., <https://crates.io>)
386    pub api_base: String,
387
388    /// Base URL for the sparse index (optional, derived from api_base if not set)
389    #[serde(default)]
390    pub index_base: Option<String>,
391
392    /// Registry token (can also be set via environment variable)
393    /// Supported formats:
394    /// - "env:VAR_NAME" - read token from environment variable
395    /// - "file:/path/to/token" - read token from file
396    /// - Raw token string (not recommended for production)
397    #[serde(default)]
398    pub token: Option<String>,
399
400    /// Whether this is the default registry (used when publishing to all registries)
401    #[serde(default)]
402    pub default: bool,
403}
404
405/// Multiple registry configuration
406#[derive(Debug, Clone, Serialize, Deserialize, Default)]
407pub struct MultiRegistryConfig {
408    /// List of registries to publish to
409    #[serde(default)]
410    pub registries: Vec<RegistryConfig>,
411
412    /// Default registries to publish to if none specified (default: ["crates-io"])
413    #[serde(default)]
414    pub default_registries: Vec<String>,
415}
416
417impl MultiRegistryConfig {
418    /// Get all registries, with crates-io as default if none configured
419    pub fn get_registries(&self) -> Vec<RegistryConfig> {
420        if self.registries.is_empty() {
421            // Return default crates-io registry
422            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    /// Get the default registry (first one marked as default, or first one, or crates-io)
435    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    /// Find a registry by name
451    pub fn find_by_name(&self, name: &str) -> Option<RegistryConfig> {
452        self.registries.iter().find(|r| r.name == name).cloned()
453    }
454}
455
456/// CLI flag overrides for merging with config file values.
457///
458/// Each `Option` field represents a flag the user may or may not have
459/// passed.  `None` means "use the config-file / default value".
460/// Boolean flags use OR semantics: `true` if either CLI or config enables it.
461///
462/// Passed to [`ShipperConfig::build_runtime_options`] to produce the
463/// final [`RuntimeOptions`].
464#[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    /// Target registries for multi-registry publishing (comma-separated list)
496    pub registries: Option<Vec<String>>,
497    /// Publish to all configured registries
498    pub all_registries: bool,
499    /// Optional package name to resume from
500    pub resume_from: Option<String>,
501    /// Rehearsal registry override — CLI flag `--rehearsal-registry <name>`.
502    /// Sets [`RehearsalConfig::registry`] and implicitly enables rehearsal.
503    /// Consumed in a follow-on PR under [#97](https://github.com/EffortlessMetrics/shipper/issues/97);
504    /// this field is parsed now so the CLI/config surface is stable.
505    pub rehearsal_registry: Option<String>,
506    /// Skip rehearsal even if config/env enables it — CLI flag `--skip-rehearsal`.
507    /// Consumed in the same follow-on PR.
508    pub skip_rehearsal: bool,
509    /// Crate name to smoke-install post-rehearsal (#97 PR 4) — CLI flag
510    /// `--smoke-install <CRATE>`. `None` means no smoke install.
511    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) // 1 hour
567}
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) // 2 minutes
579}
580
581impl ShipperConfig {
582    /// Load configuration from workspace root by searching for .shipper.toml
583    ///
584    /// Returns `Ok(None)` if no config file exists.
585    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    /// Load configuration from a specific file path
594    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        // Validate schema version
602        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    /// Validate the configuration
614    pub fn validate(&self) -> Result<()> {
615        // Validate schema version format
616        shipper_types::schema::parse_schema_version(&self.schema_version)
617            .context("invalid schema_version format")?;
618
619        // Validate output_lines
620        if self.output.lines == 0 {
621            bail!("output.lines must be greater than 0");
622        }
623
624        // Validate max_attempts
625        if self.retry.max_attempts == 0 {
626            bail!("retry.max_attempts must be greater than 0");
627        }
628
629        // Validate delays
630        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        // Validate jitter
639        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        // Validate lock_timeout
644        if self.lock.timeout.is_zero() {
645            bail!("lock.timeout must be greater than 0");
646        }
647
648        // Validate readiness config
649        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        // Validate parallel config
662        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        // Validate registry if present
671        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        // Validate multiple registries if present
681        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        // Ensure only one default registry
691        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    /// Build `RuntimeOptions` by merging CLI overrides with config file values.
705    ///
706    /// For `Option` fields: CLI value takes precedence; falls back to config.
707    /// For `bool` flags: `true` if either CLI or config enables it (OR).
708    pub fn build_runtime_options(&self, cli: CliOverrides) -> RuntimeOptions {
709        // Determine effective retry config based on policy
710        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                // CLI can override webhook settings
789                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                // Enable encryption if CLI flag is set or config enables it
800                if cli.encrypt || self.encryption.enabled {
801                    cfg.enabled = true;
802                }
803                // CLI passphrase takes precedence over config
804                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                // Use env_key from config if set
810                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                    // Default to SHIPPER_ENCRYPT_KEY if enabled but no passphrase
814                    cfg.env_var = Some("SHIPPER_ENCRYPT_KEY".to_string());
815                }
816                cfg
817            },
818            registries: {
819                // Determine target registries based on CLI overrides and config
820                if cli.all_registries {
821                    // Publish to all configured registries
822                    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                    // Publish to specifically requested registries
833                    reg_names
834                        .iter()
835                        .map(|name| {
836                            // Try to find in config, otherwise use defaults
837                            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                                    // Default to crates-io if not found
846                                    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                    // Default: single registry from the plan
860                    vec![]
861                }
862            },
863            resume_from: cli.resume_from,
864            // #97 PR 2: rehearsal resolution order, highest priority first:
865            //   1. CLI --rehearsal-registry <name>  (implicit enable)
866            //   2. config [rehearsal] enabled = true + registry = "..."
867            //   3. None → rehearsal disabled
868            //
869            // A CLI override always takes precedence, matching every other
870            // flag in this builder. --skip-rehearsal is passed through raw;
871            // engine::run_rehearsal honors it with a loud warning.
872            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    /// Generate a default configuration file content as TOML string
885    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    // ---- #97 rehearsal registry config plumbing ----
1129
1130    #[test]
1131    fn rehearsal_defaults_are_disabled_and_empty() {
1132        // An empty TOML document should produce a disabled rehearsal config.
1133        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        // Only specify enabled — registry stays None; still valid.
1163        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        // CliOverrides uses Default; rehearsal fields should be None/false by default.
1175        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        // Config sets allow_dirty, CLI doesn't
1324        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(); // readiness.enabled = true
1368
1369        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        // CLI doesn't set parallel, but config enables it
1390        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        // CLI overrides max_concurrent
1397        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); // from config
1403        assert_eq!(opts2.parallel.max_concurrent, 2); // from CLI
1404    }
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    // ── error message quality snapshots ──────────────────────────────────
1687
1688    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        /// Generate a valid `ShipperConfig` that always passes `validate()`.
1846        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,    // max_attempts
1856                1u64..3600,   // base_delay secs
1857                0u64..3600,   // extra secs added to base for max_delay
1858                0.0f64..=1.0, // jitter
1859            );
1860            let config_nums = (
1861                1usize..500, // output lines
1862                1u64..7200,  // lock_timeout secs
1863                1usize..32,  // max_concurrent
1864                1u64..7200,  // per_package_timeout secs
1865            );
1866            let booleans = (
1867                any::<bool>(), // allow_dirty
1868                any::<bool>(), // skip_ownership
1869                any::<bool>(), // strict_ownership
1870                any::<bool>(), // readiness enabled
1871                any::<bool>(), // parallel enabled
1872            );
1873            let readiness_nums = (
1874                1u64..600,    // initial_delay secs
1875                1u64..600,    // max_delay secs
1876                1u64..600,    // max_total_wait secs
1877                1u64..60,     // poll_interval secs
1878                0.0f64..=1.0, // jitter_factor
1879            );
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            /// Any valid config serializes to TOML and deserializes back identically.
2008            #[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            /// Validation always succeeds for default config, regardless of seed.
2020            #[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            /// Every generated valid config passes validation.
2027            #[test]
2028            fn generated_valid_config_passes_validation(config in arb_valid_config()) {
2029                prop_assert!(config.validate().is_ok());
2030            }
2031
2032            /// Any valid config serializes to parseable TOML.
2033            #[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            /// build_runtime_options with default (empty) CLI overrides preserves
2042            /// config-sourced values (merge idempotency for the config side).
2043            #[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    // ── Edge-case tests ─────────────────────────────────────────────
2068
2069    mod edge_cases {
2070        use super::*;
2071
2072        // 1. Completely empty TOML file
2073        #[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        // 2. TOML with only unknown sections (silently ignored)
2085        #[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        // 3. Each section individually
2117        #[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            // All others stay at defaults
2126            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        // 4. Conflicting values between sections
2274        #[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        // 5. Very long string values
2410        #[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        // 6. Unicode in config paths and values
2465        #[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        // 7. All permutations of policy presets
2523        #[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        // Additional edge cases: retry policy presets
2603        #[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        // Multi-registry edge cases
2632        #[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        // Storage edge cases
2683        #[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        // Schema version edge cases
2719        #[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        // load_from_workspace edge cases
2735        #[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        // Boundary values for numeric fields
2752        #[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        // Encryption -> RuntimeOptions merge
2796        #[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    // ── Snapshot tests for defaults and policy presets ───────────────
2836
2837    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    // ── Property tests for roundtrip ────────────────────────────────
2890
2891    mod edge_case_proptests {
2892        use super::*;
2893        use proptest::prelude::*;
2894
2895        proptest! {
2896            /// Serialize then deserialize roundtrip: the re-serialized form is identical.
2897            #[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    // ── TOML with UTF-8 BOM ─────────────────────────────────────────
2962
2963    #[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        // Write UTF-8 BOM followed by valid TOML
2969        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        // The toml crate may or may not handle BOM; we expect a clear error or success
2975        let result = ShipperConfig::load_from_file(&config_path);
2976        // toml crate >= 0.8 rejects BOM, so this should be an error
2977        // We just verify it doesn't panic
2978        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    // ── TOML with trailing whitespace on every line ──────────────────
2988
2989    #[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    // ── Empty TOML file uses all defaults ────────────────────────────
3003
3004    #[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    // ── TOML with unknown extra keys doesn't fail ────────────────────
3016
3017    #[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        // Either it ignores or rejects unknown keys - just don't panic
3029        let _ = result;
3030    }
3031
3032    // ── load_from_workspace returns None when no config ──────────────
3033
3034    #[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    // ── TOML with only whitespace ────────────────────────────────────
3042
3043    #[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    // ── TOML with Windows-style line endings (CRLF) ──────────────────
3054
3055    #[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}