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