Skip to main content

shipper_cli/
lib.rs

1//! # shipper-cli
2//!
3//! Real CLI adapter for Shipper (#95 three-crate split).
4//!
5//! This crate owns the command-line surface: argument parsing
6//! (`clap`), subcommand dispatch, help text, progress rendering. It
7//! depends on [`shipper_core`] for the actual engine.
8//!
9//! ## Architecture
10//!
11//! ```text
12//! shipper (install façade) -> shipper-cli (this crate) -> shipper-core (engine)
13//! ```
14//!
15//! The `shipper` binary on crates.io is a three-line wrapper that
16//! calls [`run`]; a separate `shipper-cli` binary exists in this
17//! crate for backward compatibility with `cargo install shipper-cli`
18//! and for workspace-local development.
19//!
20//! ## Embedding
21//!
22//! Most callers should use the `shipper` CLI directly. If you need to
23//! embed the exact CLI surface in another Rust program — for example,
24//! a wrapper that invokes `shipper` with extra preflight steps — call
25//! [`run`]. For programmatic use without a `clap` dependency, depend
26//! on [`shipper_core`](https://crates.io/crates/shipper-core) instead.
27
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use std::time::Duration;
31
32use anyhow::{Context, Result, bail};
33use clap::{CommandFactory, Parser, Subcommand};
34use clap_complete::Shell;
35
36use shipper_core::config::{CliOverrides, ShipperConfig};
37use shipper_core::engine::{self, Reporter};
38use shipper_core::plan;
39use shipper_core::types::{Finishability, PreflightReport, Registry, ReleaseSpec, RuntimeOptions};
40
41mod output;
42
43use crate::output::progress::ProgressReporter;
44
45#[derive(Parser, Debug)]
46#[command(name = "shipper", version)]
47#[command(about = "Resumable, backoff-aware crates.io publishing for workspaces")]
48struct Cli {
49    /// Path to a custom configuration file (.shipper.toml)
50    #[arg(long, global = true)]
51    config: Option<PathBuf>,
52
53    /// Path to the workspace Cargo.toml
54    #[arg(long, default_value = "Cargo.toml", global = true)]
55    manifest_path: PathBuf,
56
57    /// Cargo registry name (default: crates-io)
58    #[arg(long, global = true)]
59    registry: Option<String>,
60
61    /// Registry API base URL (default: <https://crates.io>)
62    #[arg(long, global = true)]
63    api_base: Option<String>,
64
65    /// Restrict to specific packages (repeatable). If omitted, publishes all publishable workspace members.
66    #[arg(long = "package", global = true)]
67    packages: Vec<String>,
68
69    /// Directory for shipper state and receipts (default: .shipper)
70    #[arg(long, global = true)]
71    state_dir: Option<PathBuf>,
72
73    /// Number of output lines to capture for evidence (default: 50)
74    #[arg(long, global = true)]
75    output_lines: Option<usize>,
76
77    /// Allow publishing from a dirty git working tree.
78    #[arg(long, global = true)]
79    allow_dirty: bool,
80
81    /// Skip owners/permissions preflight.
82    #[arg(long, global = true)]
83    skip_ownership_check: bool,
84
85    /// Fail preflight if ownership checks fail or if no token is available.
86    ///
87    /// Note: crates.io token scopes may not allow querying owners; this is best-effort.
88    #[arg(long, global = true)]
89    strict_ownership: bool,
90
91    /// Pass --no-verify to cargo publish.
92    #[arg(long, global = true)]
93    no_verify: bool,
94
95    /// Max attempts per crate publish step (default: 6)
96    #[arg(long, global = true)]
97    max_attempts: Option<u32>,
98
99    /// Base backoff delay (e.g. 2s, 500ms; default: 2s)
100    #[arg(long, global = true)]
101    base_delay: Option<String>,
102
103    /// Max backoff delay (e.g. 2m; default: 2m)
104    #[arg(long, global = true)]
105    max_delay: Option<String>,
106
107    /// Retry strategy: immediate, exponential (default), linear, constant
108    #[arg(long, global = true)]
109    retry_strategy: Option<String>,
110
111    /// Jitter factor for retry delays (0.0 = no jitter, 1.0 = full jitter; default: 0.5)
112    #[arg(long, global = true)]
113    retry_jitter: Option<f64>,
114
115    /// How long to wait for registry visibility after a successful publish (default: 2m)
116    #[arg(long, global = true)]
117    verify_timeout: Option<String>,
118
119    /// Poll interval for checking registry visibility (default: 5s)
120    #[arg(long, global = true)]
121    verify_poll: Option<String>,
122
123    /// Readiness check method: api (default, fast), index (slower, more accurate), both (slowest, most reliable)
124    #[arg(long, global = true)]
125    readiness_method: Option<String>,
126
127    /// How long to wait for registry visibility during readiness checks (default: 5m)
128    #[arg(long, global = true)]
129    readiness_timeout: Option<String>,
130
131    /// Poll interval for readiness checks (default: 2s)
132    #[arg(long, global = true)]
133    readiness_poll: Option<String>,
134
135    /// Disable readiness checks (for advanced users).
136    #[arg(long, global = true)]
137    no_readiness: bool,
138
139    /// Force resume even if the computed plan differs from the state file.
140    #[arg(long, global = true)]
141    force_resume: bool,
142
143    /// Force override of existing locks (use with caution)
144    #[arg(long, global = true)]
145    force: bool,
146
147    /// Lock timeout duration (e.g. 1h, 30m; default: 1h). Locks older than this are considered stale.
148    #[arg(long, global = true)]
149    lock_timeout: Option<String>,
150
151    /// Publish policy: safe (verify+strict), balanced (verify when needed), fast (no verify; default: safe)
152    #[arg(long, global = true)]
153    policy: Option<String>,
154
155    /// Verify mode: workspace (default), package (per-crate), none (no verify)
156    #[arg(long, global = true)]
157    verify_mode: Option<String>,
158
159    /// Enable parallel publishing (packages at the same dependency level are published concurrently)
160    #[arg(long, global = true)]
161    parallel: bool,
162
163    /// Maximum number of concurrent publish operations (implies --parallel)
164    #[arg(long, global = true)]
165    max_concurrent: Option<usize>,
166
167    /// Timeout per package publish operation when using parallel mode (e.g. 30m, 1h)
168    #[arg(long, global = true)]
169    per_package_timeout: Option<String>,
170
171    /// Webhook URL to send publish event notifications to
172    #[arg(long, global = true)]
173    webhook_url: Option<String>,
174
175    /// Optional secret for signing webhook payloads
176    #[arg(long, global = true)]
177    webhook_secret: Option<String>,
178
179    /// Enable encryption for state files
180    #[arg(long, global = true)]
181    encrypt: bool,
182
183    /// Passphrase for state file encryption (or use SHIPPER_ENCRYPT_KEY env var)
184    #[arg(long, global = true)]
185    encrypt_passphrase: Option<String>,
186
187    /// Target registries for multi-registry publishing (comma-separated list)
188    /// Example: --registries crates-io,my-registry
189    #[arg(long, global = true)]
190    registries: Option<String>,
191
192    /// Publish to all configured registries
193    #[arg(long, global = true)]
194    all_registries: bool,
195
196    /// Optional package name to resume from
197    #[arg(long, global = true)]
198    resume_from: Option<String>,
199
200    /// Name of a registry (from `[[registries]]` in `.shipper.toml`) to
201    /// rehearse the publish against before live dispatch.
202    ///
203    /// See issue #97. Plumbed through today; phase-2 execution (actual
204    /// publish to the rehearsal registry + install/smoke checks + live
205    /// dispatch gate) lands in a follow-on PR.
206    #[arg(long, global = true)]
207    rehearsal_registry: Option<String>,
208
209    /// Skip rehearsal even if `.shipper.toml` enables it.
210    ///
211    /// Use with caution — rehearsal (once fully implemented under #97)
212    /// is the proof boundary between "we built it" and "we verified it
213    /// actually resolves from a registry." Bypassing it should be rare.
214    #[arg(long, global = true)]
215    skip_rehearsal: bool,
216
217    /// Crate name to smoke-install after a successful rehearsal (#97 PR 4).
218    ///
219    /// Runs `cargo install --registry <rehearsal> <CRATE>` against the
220    /// rehearsal registry to prove the crate actually resolves and
221    /// installs end-to-end — the scenario that workspace-path
222    /// dependencies defeat and that killed the rc.1 first-publish.
223    ///
224    /// The named crate must be in the plan AND have a `[[bin]]` target.
225    /// Library-only crates cannot be smoke-installed directly; use a
226    /// consumer-workspace build instead (follow-on).
227    #[arg(long = "smoke-install", global = true, value_name = "CRATE")]
228    rehearsal_smoke_install: Option<String>,
229
230    /// Output format: text (default) or json
231    #[arg(long, default_value = "text", value_parser = ["text", "json"], global = true)]
232    format: String,
233
234    /// Show detailed dependency analysis for plan command
235    #[arg(long, global = true)]
236    verbose: bool,
237
238    /// Suppress informational output
239    #[arg(short, long, global = true)]
240    quiet: bool,
241
242    #[command(subcommand)]
243    cmd: Commands,
244}
245
246#[derive(Subcommand, Debug)]
247enum Commands {
248    /// Print the deterministic publish plan (dependency-first ordering).
249    Plan,
250    /// Run preflight checks without publishing.
251    Preflight,
252    /// Execute the plan (will resume if a matching state file exists).
253    Publish,
254    /// Resume a previous publish run.
255    Resume,
256    /// Rehearse a release against an alternate registry (#97 PR 2).
257    ///
258    /// Publishes every crate in the plan to the registry named by
259    /// `--rehearsal-registry` (or `[rehearsal] registry = "..."` in
260    /// `.shipper.toml`), verifies visibility on that registry, and
261    /// emits a `RehearsalComplete { passed, ... }` event to
262    /// `events.jsonl` so the outcome is auditable.
263    ///
264    /// Rehearse must target a non-live registry (kellnr, a sandbox
265    /// crates.io account, or a throwaway alternate registry). Shipper
266    /// refuses to rehearse against the same registry as the live target.
267    ///
268    /// Part of [#97](https://github.com/EffortlessMetrics/shipper/issues/97).
269    /// The hard gate that blocks live publish without a passing rehearsal
270    /// lands in #97 PR 3.
271    Rehearse,
272    /// Compare local workspace versions to the registry.
273    Status,
274    /// Print environment and auth diagnostics.
275    Doctor,
276    /// View detailed event log.
277    InspectEvents,
278    /// View detailed receipt with evidence.
279    InspectReceipt,
280    /// Print CI configuration snippets for various platforms.
281    #[command(subcommand)]
282    Ci(CiCommands),
283    /// Clean state files (state.json, receipt.json, events.jsonl).
284    Clean {
285        /// Keep receipt.json (only remove state.json and events.jsonl)
286        #[arg(long)]
287        keep_receipt: bool,
288    },
289    /// Yank a crate@version from the registry — containment, not undo.
290    ///
291    /// `cargo yank` marks a specific version as not-installable for NEW
292    /// dependency resolves. Existing lockfile pins and already-downloaded
293    /// copies are unaffected. See
294    /// [cargo yank docs](https://doc.rust-lang.org/cargo/commands/cargo-yank.html).
295    ///
296    /// Part of [#98 Remediate](https://github.com/EffortlessMetrics/shipper/issues/98).
297    /// Follow-on commands (`shipper plan-yank`, `shipper fix-forward`)
298    /// compose this primitive into reverse-topological containment and
299    /// fix-forward plans.
300    Yank {
301        /// Name of the crate to yank (e.g., `shipper-types`). Required
302        /// unless `--plan` is supplied.
303        #[arg(long = "crate", value_name = "NAME", conflicts_with = "plan")]
304        crate_name: Option<String>,
305        /// Version to yank (e.g., `1.2.3`). Required unless `--plan`
306        /// is supplied.
307        #[arg(long, value_name = "VERSION", conflicts_with = "plan")]
308        version: Option<String>,
309        /// Operator-supplied reason. Required unless `--plan` is supplied.
310        /// Recorded in the event log, audit trails, and any future
311        /// receipts that reference this yank.
312        ///
313        /// Example: `"CVE-2026-0001 disclosed; containing while patch
314        /// released"`.
315        #[arg(long, conflicts_with = "plan")]
316        reason: Option<String>,
317        /// Also mark the crate's existing receipt entry as compromised
318        /// (#98 PR 3). Ignored in `--plan` mode (plan execution already
319        /// carries per-entry reasons from the planning step).
320        #[arg(long)]
321        mark_compromised: bool,
322        /// **Plan execution mode** (#98 PR 5). Path to a yank plan JSON
323        /// file (the `--format json` output of `shipper plan-yank`).
324        /// Walks the plan's entries in order, invoking `cargo yank` for
325        /// each. Mutually exclusive with `--crate` / `--version` /
326        /// `--reason`.
327        #[arg(long, value_name = "PATH")]
328        plan: Option<PathBuf>,
329    },
330    /// Generate a reverse-topological yank plan from a receipt (#98 PR 2).
331    ///
332    /// Reads a prior `receipt.json` and emits the order in which to yank
333    /// the released crates — dependents first, dependencies last — so
334    /// downstream consumers stop resolving against the bad version before
335    /// the bad version itself is pulled. Output is either human-readable
336    /// `shipper yank ...` lines or structured JSON for scripting.
337    ///
338    /// **Planning only.** This command does NOT execute yanks. Pipe the
339    /// output through `sh`, or consume the JSON, once you've reviewed it.
340    /// `shipper fix-forward` (#98 PR 3) will wrap execution.
341    PlanYank {
342        /// Path to the receipt to derive the plan from. Defaults to
343        /// `<state_dir>/receipt.json` when omitted.
344        #[arg(long, value_name = "PATH")]
345        from_receipt: Option<PathBuf>,
346        /// Restrict the plan to packages whose receipt carries a
347        /// `compromised_at` marker. Without this, every `Published`
348        /// package is included (full rollback). Mutually exclusive
349        /// with `--starting-crate`.
350        #[arg(long, conflicts_with = "starting_crate")]
351        compromised_only: bool,
352        /// **Graph mode** (#98 PR 4). Given a specific broken crate
353        /// name, walk the workspace's dependency graph to find every
354        /// crate that transitively depends on it, and emit a yank
355        /// plan covering only that affected chain (not a full
356        /// rollback). Resolves the graph from the current workspace's
357        /// `Cargo.toml` metadata — the receipt supplies the versions
358        /// and Published-state filter.
359        #[arg(long, value_name = "CRATE")]
360        starting_crate: Option<String>,
361        /// Per-entry reason to embed in the yank plan (applied to
362        /// every entry). If omitted, each entry's reason falls back
363        /// to its receipt-level `compromised_by` field (if set).
364        #[arg(long, value_name = "REASON")]
365        reason: Option<String>,
366    },
367    /// Generate a fix-forward supersession plan from a compromised
368    /// receipt (#98 PR 3).
369    ///
370    /// Reads a prior `receipt.json`, finds packages whose receipt entry
371    /// carries a `compromised_at` marker (populated by
372    /// `shipper yank ... --mark-compromised`), and prints an ordered
373    /// list of successor versions to publish. Dependencies go first
374    /// (opposite of plan-yank) so downstream consumers can upgrade to a
375    /// clean chain on `cargo update`.
376    ///
377    /// **Planning only.** This command does NOT edit Cargo.toml or
378    /// invoke publish — that's operator territory. It prints the
379    /// steps, you execute them.
380    #[command(name = "fix-forward")]
381    FixForward {
382        /// Path to the compromised receipt. Defaults to
383        /// `<state_dir>/receipt.json` when omitted.
384        #[arg(long, value_name = "PATH")]
385        from_receipt: Option<PathBuf>,
386    },
387    /// Configuration file management.
388    #[command(subcommand)]
389    Config(ConfigCommands),
390    /// Generate shell completion scripts for the specified shell.
391    Completion {
392        /// Shell to generate completions for.
393        #[arg(value_enum)]
394        shell: Shell,
395    },
396}
397
398#[derive(Subcommand, Debug)]
399enum CiCommands {
400    /// Print GitHub Actions workflow snippet.
401    #[command(name = "github-actions")]
402    GitHubActions,
403    /// Print GitLab CI workflow snippet.
404    #[command(name = "gitlab")]
405    GitLab,
406    /// Print CircleCI workflow snippet.
407    #[command(name = "circleci")]
408    CircleCI,
409    /// Print Azure DevOps pipeline snippet.
410    #[command(name = "azure-devops")]
411    AzureDevOps,
412}
413
414#[derive(Subcommand, Debug, Clone)]
415enum ConfigCommands {
416    /// Generate a default .shipper.toml configuration file.
417    Init {
418        /// Output path for the configuration file (default: .shipper.toml)
419        #[arg(short, long, default_value = ".shipper.toml")]
420        output: PathBuf,
421    },
422    /// Validate a configuration file.
423    Validate {
424        /// Path to the configuration file to validate (default: .shipper.toml)
425        #[arg(short, long, default_value = ".shipper.toml")]
426        path: PathBuf,
427    },
428}
429
430struct CliReporter {
431    quiet: bool,
432}
433
434impl Reporter for CliReporter {
435    fn info(&mut self, msg: &str) {
436        if !self.quiet {
437            eprintln!("[info] {msg}");
438        }
439    }
440
441    fn warn(&mut self, msg: &str) {
442        if !self.quiet {
443            eprintln!("[warn] {msg}");
444        }
445    }
446
447    fn error(&mut self, msg: &str) {
448        eprintln!("[error] {msg}");
449    }
450}
451
452/// CLI entry point. Exposed for the `shipper` crate's binary target
453/// and for the `shipper-cli` crate's own `shipper-cli` binary — both
454/// are three-line `fn main() { shipper_cli::run() }` wrappers over
455/// this function.
456pub fn run() -> Result<()> {
457    let cli = Cli::parse();
458
459    // Handle Config commands early (they don't need workspace plan)
460    if let Commands::Config(config_cmd) = &cli.cmd {
461        return run_config(config_cmd.clone());
462    }
463
464    // Handle Completion commands early (they don't need workspace plan)
465    if let Commands::Completion { shell } = &cli.cmd {
466        return run_completion(shell);
467    }
468
469    let api_base = cli
470        .api_base
471        .clone()
472        .unwrap_or_else(|| "https://crates.io".to_string());
473    let index_base = cli.api_base.as_ref().map(|_| api_base.clone());
474
475    let spec = ReleaseSpec {
476        manifest_path: cli.manifest_path.clone(),
477        registry: Registry {
478            name: cli
479                .registry
480                .clone()
481                .unwrap_or_else(|| "crates-io".to_string()),
482            api_base,
483            index_base,
484        },
485        selected_packages: if cli.packages.is_empty() {
486            None
487        } else {
488            Some(cli.packages.clone())
489        },
490    };
491
492    let mut planned = plan::build_plan(&spec)?;
493
494    // Load configuration file
495    let config =
496        if let Some(ref config_path) = cli.config {
497            // Use custom config file specified via --config
498            Some(ShipperConfig::load_from_file(config_path).with_context(|| {
499                format!("Failed to load config from: {}", config_path.display())
500            })?)
501        } else {
502            // Try to load .shipper.toml from workspace root
503            ShipperConfig::load_from_workspace(&planned.workspace_root)
504                .with_context(|| "Failed to load config from workspace")?
505        };
506
507    // Validate loaded configuration before using it for runtime options.
508    if let Some(ref cfg) = config {
509        let config_path = cli
510            .config
511            .clone()
512            .unwrap_or_else(|| planned.workspace_root.join(".shipper.toml"));
513        cfg.validate().with_context(|| {
514            format!(
515                "Configuration validation failed for {}",
516                config_path.display()
517            )
518        })?;
519    }
520
521    // Apply registry from config if CLI didn't set it
522    if let Some(ref cfg) = config
523        && let Some(ref reg_config) = cfg.registry
524    {
525        if cli.registry.is_none() {
526            planned.plan.registry.name = reg_config.name.clone();
527        }
528        if cli.api_base.is_none() {
529            planned.plan.registry.api_base = reg_config.api_base.clone();
530            planned.plan.registry.index_base = reg_config.index_base.clone();
531        }
532    }
533
534    // Build CLI overrides
535    let cli_overrides = CliOverrides {
536        policy: cli.policy.as_deref().map(parse_policy).transpose()?,
537        verify_mode: cli
538            .verify_mode
539            .as_deref()
540            .map(parse_verify_mode)
541            .transpose()?,
542        max_attempts: cli.max_attempts,
543        base_delay: cli.base_delay.as_deref().map(parse_duration).transpose()?,
544        max_delay: cli.max_delay.as_deref().map(parse_duration).transpose()?,
545        retry_strategy: cli
546            .retry_strategy
547            .as_deref()
548            .map(parse_retry_strategy)
549            .transpose()?,
550        retry_jitter: cli.retry_jitter,
551        verify_timeout: cli
552            .verify_timeout
553            .as_deref()
554            .map(parse_duration)
555            .transpose()?,
556        verify_poll_interval: cli.verify_poll.as_deref().map(parse_duration).transpose()?,
557        output_lines: cli.output_lines,
558        lock_timeout: cli
559            .lock_timeout
560            .as_deref()
561            .map(parse_duration)
562            .transpose()?,
563        state_dir: cli.state_dir.clone(),
564        readiness_method: cli
565            .readiness_method
566            .as_deref()
567            .map(parse_readiness_method)
568            .transpose()?,
569        readiness_timeout: cli
570            .readiness_timeout
571            .as_deref()
572            .map(parse_duration)
573            .transpose()?,
574        readiness_poll: cli
575            .readiness_poll
576            .as_deref()
577            .map(parse_duration)
578            .transpose()?,
579        allow_dirty: cli.allow_dirty,
580        skip_ownership_check: cli.skip_ownership_check,
581        strict_ownership: cli.strict_ownership,
582        no_verify: cli.no_verify,
583        no_readiness: cli.no_readiness,
584        force: cli.force,
585        force_resume: cli.force_resume,
586        parallel_enabled: cli.parallel || cli.max_concurrent.is_some(),
587        max_concurrent: cli.max_concurrent,
588        per_package_timeout: cli
589            .per_package_timeout
590            .as_deref()
591            .map(parse_duration)
592            .transpose()?,
593        webhook_url: cli.webhook_url.clone(),
594        webhook_secret: cli.webhook_secret.clone(),
595        encrypt: cli.encrypt,
596        encrypt_passphrase: cli.encrypt_passphrase.clone(),
597        registries: cli.registries.as_ref().map(|s| {
598            s.split(',')
599                .map(|s| s.trim().to_string())
600                .filter(|s| !s.is_empty())
601                .collect()
602        }),
603        all_registries: cli.all_registries,
604        resume_from: cli.resume_from.clone(),
605        rehearsal_registry: cli.rehearsal_registry.clone(),
606        skip_rehearsal: cli.skip_rehearsal,
607        rehearsal_smoke_install: cli.rehearsal_smoke_install.clone(),
608    };
609
610    // Merge CLI overrides with config (or defaults if no config)
611    let config_for_merge = config.clone().unwrap_or_default();
612    let opts: RuntimeOptions = config_for_merge.build_runtime_options(cli_overrides);
613
614    let mut reporter = CliReporter { quiet: cli.quiet };
615
616    match cli.cmd {
617        Commands::Plan => {
618            print_plan(&planned, cli.verbose);
619        }
620        Commands::Preflight => {
621            let rep = engine::run_preflight(&planned, &opts, &mut reporter)?;
622            print_preflight(&rep, &cli.format);
623        }
624        Commands::Publish => {
625            let target_registries = if opts.registries.is_empty() {
626                vec![planned.plan.registry.clone()]
627            } else {
628                opts.registries.clone()
629            };
630
631            for reg in target_registries {
632                if opts.registries.len() > 1 {
633                    println!(
634                        "\n🚀 Publishing to registry: {} ({})",
635                        reg.name, reg.api_base
636                    );
637                }
638
639                let mut current_planned = planned.clone();
640                current_planned.plan.registry = reg.clone();
641
642                let mut current_opts = opts.clone();
643                // Segregate state dir by registry name if multiple registries
644                if opts.registries.len() > 1 {
645                    current_opts.state_dir = opts.state_dir.join(&reg.name);
646                }
647
648                let total_packages = current_planned.plan.packages.len();
649                let mut progress = ProgressReporter::new(total_packages, cli.quiet);
650
651                // Show initial progress if we have packages
652                if total_packages > 0 {
653                    let first_pkg = &current_planned.plan.packages[0];
654                    progress.set_package(1, &first_pkg.name, &first_pkg.version);
655                }
656
657                let receipt = engine::run_publish(&current_planned, &current_opts, &mut reporter)?;
658
659                progress.finish();
660
661                print_receipt(
662                    &receipt,
663                    &current_planned.workspace_root,
664                    &current_opts.state_dir,
665                    &cli.format,
666                );
667            }
668        }
669        Commands::Resume => {
670            let target_registries = if opts.registries.is_empty() {
671                vec![planned.plan.registry.clone()]
672            } else {
673                opts.registries.clone()
674            };
675
676            for reg in target_registries {
677                if opts.registries.len() > 1 {
678                    println!(
679                        "\n🔄 Resuming for registry: {} ({})",
680                        reg.name, reg.api_base
681                    );
682                }
683
684                let mut current_planned = planned.clone();
685                current_planned.plan.registry = reg.clone();
686
687                let mut current_opts = opts.clone();
688                if opts.registries.len() > 1 {
689                    current_opts.state_dir = opts.state_dir.join(&reg.name);
690                }
691
692                let total_packages = current_planned.plan.packages.len();
693                let mut progress = ProgressReporter::new(total_packages, cli.quiet);
694
695                // Show initial progress if we have packages
696                if total_packages > 0 {
697                    let first_pkg = &current_planned.plan.packages[0];
698                    progress.set_package(1, &first_pkg.name, &first_pkg.version);
699                }
700
701                let receipt = engine::run_resume(&current_planned, &current_opts, &mut reporter)?;
702
703                progress.finish();
704
705                print_receipt(
706                    &receipt,
707                    &current_planned.workspace_root,
708                    &current_opts.state_dir,
709                    &cli.format,
710                );
711            }
712        }
713        Commands::Rehearse => {
714            let outcome = engine::run_rehearsal(&planned, &opts, &mut reporter)?;
715
716            // Stdout is the operator-facing receipt: mirrors the live
717            // publish path, so a human scanning the terminal sees one
718            // consistent "did it work?" line regardless of which command
719            // they ran. Full per-package detail is in events.jsonl.
720            if outcome.passed {
721                println!(
722                    "rehearsal OK: {} packages against '{}'",
723                    outcome.packages_published, outcome.registry_name
724                );
725            } else {
726                println!(
727                    "rehearsal FAILED after {}/{} packages against '{}': {}",
728                    outcome.packages_published,
729                    outcome.packages_attempted,
730                    outcome.registry_name,
731                    outcome.summary
732                );
733                // Exit non-zero so CI lanes that wrap `shipper rehearse`
734                // fail the job on a failed rehearsal without needing extra
735                // scripting.
736                anyhow::bail!("rehearsal did not pass");
737            }
738        }
739        Commands::Status => {
740            let target_registries = if opts.registries.is_empty() {
741                vec![planned.plan.registry.clone()]
742            } else {
743                opts.registries.clone()
744            };
745
746            for reg in target_registries {
747                if opts.registries.len() > 1 {
748                    println!("\n📊 Status for registry: {} ({})", reg.name, reg.api_base);
749                }
750                let mut current_planned = planned.clone();
751                current_planned.plan.registry = reg;
752                run_status(&current_planned, &mut reporter)?;
753            }
754        }
755        Commands::Doctor => {
756            let target_registries = if opts.registries.is_empty() {
757                vec![planned.plan.registry.clone()]
758            } else {
759                opts.registries.clone()
760            };
761
762            for reg in target_registries {
763                if opts.registries.len() > 1 {
764                    println!(
765                        "\n🩺 Diagnostics for registry: {} ({})",
766                        reg.name, reg.api_base
767                    );
768                }
769                let mut current_planned = planned.clone();
770                current_planned.plan.registry = reg;
771                run_doctor(&current_planned, &opts, &mut reporter)?;
772            }
773        }
774        Commands::InspectEvents => {
775            run_inspect_events(&planned, &opts)?;
776        }
777        Commands::InspectReceipt => {
778            run_inspect_receipt(&planned, &opts, &cli.format)?;
779        }
780        Commands::Ci(ci_cmd) => {
781            run_ci(ci_cmd, &opts.state_dir, &planned.workspace_root)?;
782        }
783        Commands::Yank {
784            crate_name,
785            version,
786            reason,
787            mark_compromised,
788            plan,
789        } => {
790            use shipper_core::cargo;
791            use shipper_core::engine::plan_yank;
792            use shipper_core::state::events::{EventLog, events_path};
793            use shipper_core::state::execution_state::{load_receipt, receipt_path, write_receipt};
794            use shipper_core::types::{EventType, PublishEvent};
795
796            // #98 PR 5 — plan execution mode. Dispatched entirely
797            // separately from the single-yank path below; the two share
798            // the same cargo_yank primitive but different orchestration.
799            if let Some(plan_path) = plan {
800                let yank_plan = plan_yank::load_plan_from_path(&plan_path)?;
801                reporter.info(&format!(
802                    "executing yank plan: {} entries against '{}' (plan_id {})",
803                    yank_plan.entries.len(),
804                    yank_plan.registry,
805                    yank_plan.plan_id
806                ));
807
808                let workspace_root = std::env::current_dir()
809                    .context("failed to resolve current dir for plan execution")?;
810                let registry_name = opts
811                    .registries
812                    .first()
813                    .map(|r| r.name.clone())
814                    .unwrap_or_else(|| yank_plan.registry.clone());
815
816                let mut log = EventLog::new();
817                let events_file = events_path(&opts.state_dir);
818
819                let mut succeeded = 0usize;
820                let mut failed: Option<(String, i32)> = None;
821
822                for (i, entry) in yank_plan.entries.iter().enumerate() {
823                    let entry_reason = entry
824                        .reason
825                        .clone()
826                        .unwrap_or_else(|| "plan execution".to_string());
827                    reporter.warn(&format!(
828                        "[{}/{}] yanking {}@{} — reason: {}",
829                        i + 1,
830                        yank_plan.entries.len(),
831                        entry.name,
832                        entry.version,
833                        entry_reason
834                    ));
835
836                    let out = cargo::cargo_yank(
837                        &workspace_root,
838                        entry.name.as_str(),
839                        entry.version.as_str(),
840                        registry_name.as_str(),
841                        opts.output_lines,
842                        None,
843                    )?;
844
845                    log.record(PublishEvent {
846                        timestamp: chrono::Utc::now(),
847                        event_type: EventType::PackageYanked {
848                            crate_name: entry.name.clone(),
849                            version: entry.version.clone(),
850                            reason: entry_reason.clone(),
851                            exit_code: out.exit_code,
852                        },
853                        package: format!("{}@{}", entry.name, entry.version),
854                    });
855                    if let Err(err) = log.write_to_file(&events_file) {
856                        reporter.warn(&format!(
857                            "failed to append PackageYanked event to {}: {err:#}",
858                            events_file.display()
859                        ));
860                    }
861                    log.clear();
862
863                    if out.exit_code == 0 {
864                        succeeded += 1;
865                        reporter.info(&format!(
866                            "[{}/{}] yanked {}@{}",
867                            i + 1,
868                            yank_plan.entries.len(),
869                            entry.name,
870                            entry.version
871                        ));
872                    } else {
873                        reporter.error(&format!(
874                            "[{}/{}] cargo yank exited {} for {}@{}. stderr tail:\n{}",
875                            i + 1,
876                            yank_plan.entries.len(),
877                            out.exit_code,
878                            entry.name,
879                            entry.version,
880                            out.stderr_tail
881                        ));
882                        failed = Some((format!("{}@{}", entry.name, entry.version), out.exit_code));
883                        // Halt on first failure. Plan is reverse-topo so
884                        // every entry below this one is a dependent of
885                        // something we just failed to yank — continuing
886                        // would only produce more damage.
887                        break;
888                    }
889                }
890
891                if let Some((pkg, code)) = failed {
892                    reporter.error(&format!(
893                        "yank plan halted: {succeeded}/{} succeeded; failed at {pkg} (cargo exit {code})",
894                        yank_plan.entries.len()
895                    ));
896                    anyhow::bail!(
897                        "yank plan failed at {pkg}; {succeeded}/{} entries succeeded before halt",
898                        yank_plan.entries.len()
899                    );
900                } else {
901                    reporter.info(&format!(
902                        "yank plan complete: {succeeded}/{} entries yanked successfully",
903                        yank_plan.entries.len()
904                    ));
905                    return Ok(());
906                }
907            }
908
909            // Single-yank mode (the original shape). All three fields
910            // are required when `--plan` is absent; clap's
911            // `conflicts_with` already rejected the mixed combinations.
912            let crate_name = crate_name.ok_or_else(|| {
913                anyhow::anyhow!("--crate is required when --plan is not supplied")
914            })?;
915            let version = version.ok_or_else(|| {
916                anyhow::anyhow!("--version is required when --plan is not supplied")
917            })?;
918            let reason = reason.ok_or_else(|| {
919                anyhow::anyhow!("--reason is required when --plan is not supplied")
920            })?;
921
922            reporter.warn(&format!(
923                "yanking {crate_name}@{version} from registry \
924                 (containment, not undo) — reason: {reason}"
925            ));
926
927            let workspace_root =
928                std::env::current_dir().context("failed to resolve current dir for cargo yank")?;
929            let registry_name = opts
930                .registries
931                .first()
932                .map(|r| r.name.clone())
933                .unwrap_or_else(|| "crates-io".to_string());
934
935            let out = cargo::cargo_yank(
936                &workspace_root,
937                crate_name.as_str(),
938                version.as_str(),
939                registry_name.as_str(),
940                opts.output_lines,
941                None,
942            )?;
943
944            let mut log = EventLog::new();
945            log.record(PublishEvent {
946                timestamp: chrono::Utc::now(),
947                event_type: EventType::PackageYanked {
948                    crate_name: crate_name.clone(),
949                    version: version.clone(),
950                    reason: reason.clone(),
951                    exit_code: out.exit_code,
952                },
953                package: format!("{crate_name}@{version}"),
954            });
955            let events_file = events_path(&opts.state_dir);
956            if let Err(err) = log.write_to_file(&events_file) {
957                reporter.warn(&format!(
958                    "failed to append PackageYanked event to {}: {err:#}",
959                    events_file.display()
960                ));
961            }
962
963            if out.exit_code == 0 {
964                if mark_compromised {
965                    // #98 PR 3: mirror the yank into the receipt so
966                    // downstream commands (plan-yank --compromised-only,
967                    // fix-forward) can find the marker without scanning
968                    // events.jsonl. The receipt is a *projection*, so
969                    // mutating one field on one matching package is a
970                    // legitimate amendment.
971                    let rpath = receipt_path(&opts.state_dir);
972                    match load_receipt(&opts.state_dir) {
973                        Ok(Some(mut receipt)) => {
974                            let matched = receipt
975                                .packages
976                                .iter_mut()
977                                .find(|p| p.name == crate_name && p.version == version);
978                            if let Some(pkg) = matched {
979                                pkg.compromised_at = Some(chrono::Utc::now());
980                                pkg.compromised_by = Some(reason.clone());
981                                if let Err(err) = write_receipt(&opts.state_dir, &receipt) {
982                                    reporter.warn(&format!(
983                                        "yanked successfully but failed to mark receipt at \
984                                         {}: {err:#}",
985                                        rpath.display()
986                                    ));
987                                } else {
988                                    reporter.info(&format!(
989                                        "marked {crate_name}@{version} compromised in {}",
990                                        rpath.display()
991                                    ));
992                                }
993                            } else {
994                                reporter.warn(&format!(
995                                    "--mark-compromised: no matching package entry for \
996                                     {crate_name}@{version} in {}; yank succeeded but the \
997                                     receipt was not amended.",
998                                    rpath.display()
999                                ));
1000                            }
1001                        }
1002                        Ok(None) => {
1003                            reporter.warn(&format!(
1004                                "--mark-compromised: no receipt at {}; yank succeeded but \
1005                                 nothing to amend. Future plan-yank / fix-forward runs won't \
1006                                 see this version as compromised unless the receipt is \
1007                                 reconstructed.",
1008                                rpath.display()
1009                            ));
1010                        }
1011                        Err(err) => {
1012                            reporter.warn(&format!(
1013                                "--mark-compromised: failed to load receipt at {}: {err:#}. \
1014                                 Yank succeeded; receipt not amended.",
1015                                rpath.display()
1016                            ));
1017                        }
1018                    }
1019                }
1020
1021                reporter.info(&format!(
1022                    "yanked {crate_name}@{version} successfully. \
1023                     existing lockfile pins are NOT invalidated; \
1024                     downstream consumers should `cargo update -p {crate_name}` \
1025                     to pick up the next available version."
1026                ));
1027            } else {
1028                reporter.error(&format!(
1029                    "cargo yank exited {} for {crate_name}@{version}. \
1030                     stderr tail:\n{}",
1031                    out.exit_code, out.stderr_tail
1032                ));
1033                anyhow::bail!(
1034                    "yank failed for {crate_name}@{version} (cargo exit {})",
1035                    out.exit_code
1036                );
1037            }
1038        }
1039        Commands::PlanYank {
1040            from_receipt,
1041            compromised_only,
1042            starting_crate,
1043            reason,
1044        } => {
1045            use shipper_core::engine::plan_yank::{self, PlanYankFilter};
1046
1047            let receipt_path = from_receipt.unwrap_or_else(|| {
1048                opts.state_dir
1049                    .join(shipper_core::state::execution_state::RECEIPT_FILE)
1050            });
1051
1052            let receipt = plan_yank::load_receipt_from_path(&receipt_path).with_context(|| {
1053                "plan-yank needs a readable receipt; default path is \
1054                 <state_dir>/receipt.json. Pass --from-receipt <path> to \
1055                 override."
1056                    .to_string()
1057            })?;
1058
1059            // Three mutually-informative modes:
1060            //   --starting-crate <N>   → graph mode (walk dependents)
1061            //   --compromised-only     → receipt-filter mode (marker)
1062            //   (default)              → receipt-filter mode (all Published)
1063            // clap's `conflicts_with` already rejects combinations at parse time.
1064            let plan = if let Some(ref starting) = starting_crate {
1065                // Graph mode uses the *current workspace's* dependency graph,
1066                // read from the planned workspace we already built upstream.
1067                plan_yank::build_plan_from_starting_crate(
1068                    &receipt,
1069                    &planned.plan.dependencies,
1070                    starting,
1071                    reason.clone(),
1072                )?
1073            } else {
1074                let filter = if compromised_only {
1075                    PlanYankFilter::CompromisedOnly
1076                } else {
1077                    PlanYankFilter::AllPublished
1078                };
1079                plan_yank::build_plan(&receipt, filter)
1080            };
1081
1082            match cli.format.as_str() {
1083                "json" => {
1084                    let out = serde_json::to_string_pretty(&plan)
1085                        .context("failed to serialize yank plan as JSON")?;
1086                    println!("{out}");
1087                }
1088                _ => {
1089                    println!("{}", plan_yank::render_text(&plan));
1090                }
1091            }
1092        }
1093        Commands::FixForward { from_receipt } => {
1094            use shipper_core::engine::fix_forward::{self, SuccessorStrategy};
1095
1096            let receipt_path = from_receipt.unwrap_or_else(|| {
1097                opts.state_dir
1098                    .join(shipper_core::state::execution_state::RECEIPT_FILE)
1099            });
1100
1101            let plan =
1102                fix_forward::plan_from_path(&receipt_path, SuccessorStrategy::PlaceholderNext)
1103                    .with_context(|| {
1104                        "fix-forward needs a readable receipt; default path is \
1105                         <state_dir>/receipt.json. Pass --from-receipt <path> to \
1106                         override."
1107                            .to_string()
1108                    })?;
1109
1110            match cli.format.as_str() {
1111                "json" => {
1112                    let out = serde_json::to_string_pretty(&plan)
1113                        .context("failed to serialize fix-forward plan as JSON")?;
1114                    println!("{out}");
1115                }
1116                _ => {
1117                    println!("{}", fix_forward::render_text(&plan));
1118                }
1119            }
1120        }
1121        Commands::Clean { keep_receipt } => {
1122            run_clean(
1123                &opts.state_dir,
1124                &planned.workspace_root,
1125                keep_receipt,
1126                opts.force,
1127            )?;
1128        }
1129        Commands::Config(_) => {
1130            // This should never be reached since we handle Config commands early
1131            unreachable!("Config commands should be handled before this match");
1132        }
1133        Commands::Completion { .. } => {
1134            // This should never be reached since we handle Completion commands early
1135            unreachable!("Completion commands should be handled before this match");
1136        }
1137    }
1138
1139    Ok(())
1140}
1141
1142fn parse_duration(s: &str) -> Result<Duration> {
1143    shipper_duration::parse_duration(s).with_context(|| format!("invalid duration: {s}"))
1144}
1145
1146fn parse_policy(s: &str) -> Result<shipper_core::config::PublishPolicy> {
1147    match s.to_lowercase().as_str() {
1148        "safe" => Ok(shipper_core::config::PublishPolicy::Safe),
1149        "balanced" => Ok(shipper_core::config::PublishPolicy::Balanced),
1150        "fast" => Ok(shipper_core::config::PublishPolicy::Fast),
1151        _ => bail!("invalid policy: {s} (expected: safe, balanced, fast)"),
1152    }
1153}
1154
1155fn parse_verify_mode(s: &str) -> Result<shipper_core::config::VerifyMode> {
1156    match s.to_lowercase().as_str() {
1157        "workspace" => Ok(shipper_core::config::VerifyMode::Workspace),
1158        "package" => Ok(shipper_core::config::VerifyMode::Package),
1159        "none" => Ok(shipper_core::config::VerifyMode::None),
1160        _ => bail!("invalid verify-mode: {s} (expected: workspace, package, none)"),
1161    }
1162}
1163
1164fn parse_readiness_method(s: &str) -> Result<shipper_core::config::ReadinessMethod> {
1165    match s.to_lowercase().as_str() {
1166        "api" => Ok(shipper_core::config::ReadinessMethod::Api),
1167        "index" => Ok(shipper_core::config::ReadinessMethod::Index),
1168        "both" => Ok(shipper_core::config::ReadinessMethod::Both),
1169        _ => bail!("invalid readiness-method: {s} (expected: api, index, both)"),
1170    }
1171}
1172
1173fn parse_retry_strategy(s: &str) -> Result<shipper_core::retry::RetryStrategyType> {
1174    match s.to_lowercase().as_str() {
1175        "immediate" => Ok(shipper_core::retry::RetryStrategyType::Immediate),
1176        "exponential" => Ok(shipper_core::retry::RetryStrategyType::Exponential),
1177        "linear" => Ok(shipper_core::retry::RetryStrategyType::Linear),
1178        "constant" => Ok(shipper_core::retry::RetryStrategyType::Constant),
1179        _ => bail!(
1180            "invalid retry-strategy: {s} (expected: immediate, exponential, linear, constant)"
1181        ),
1182    }
1183}
1184
1185fn print_plan(ws: &plan::PlannedWorkspace, verbose: bool) {
1186    println!("plan_id: {}", ws.plan.plan_id);
1187    println!(
1188        "registry: {} ({})",
1189        ws.plan.registry.name, ws.plan.registry.api_base
1190    );
1191    println!("workspace_root: {}", ws.workspace_root.display());
1192    println!();
1193
1194    let total_packages = ws.plan.packages.len();
1195    println!("Total packages to publish: {}", total_packages);
1196    println!();
1197
1198    if !ws.skipped.is_empty() {
1199        println!("Skipped packages:");
1200        for p in &ws.skipped {
1201            println!("  - {}@{} ({})", p.name, p.version, p.reason);
1202        }
1203        println!();
1204    }
1205
1206    if verbose {
1207        // Enhanced verbose output with dependency analysis
1208        print_detailed_plan(ws);
1209    } else {
1210        // Simple output
1211        for (idx, p) in ws.plan.packages.iter().enumerate() {
1212            println!("{:>3}. {}@{}", idx + 1, p.name, p.version);
1213        }
1214    }
1215}
1216
1217fn print_detailed_plan(ws: &plan::PlannedWorkspace) {
1218    // Get dependency levels for parallel publishing analysis
1219    let levels = ws.plan.group_by_levels();
1220    let total_levels = levels.len();
1221
1222    println!("=== Dependency Analysis ===");
1223    println!();
1224
1225    // Show dependency levels for parallel publishing
1226    println!("Publishing Levels (packages at same level can be published in parallel):");
1227    println!();
1228    for level in &levels {
1229        let level_pkgs: Vec<String> = level
1230            .packages
1231            .iter()
1232            .map(|p| format!("{}@{}", p.name, p.version))
1233            .collect();
1234        println!("  Level {}: {}", level.level, level_pkgs.join(", "));
1235    }
1236    println!();
1237
1238    // Show full dependency graph
1239    println!("Dependency Graph:");
1240    println!();
1241    for (idx, p) in ws.plan.packages.iter().enumerate() {
1242        let deps = ws.plan.dependencies.get(&p.name);
1243        let deps_str = match deps {
1244            Some(deps) if !deps.is_empty() => {
1245                let dep_versions: Vec<String> = deps
1246                    .iter()
1247                    .filter_map(|dep_name| {
1248                        ws.plan
1249                            .packages
1250                            .iter()
1251                            .find(|pkg| &pkg.name == dep_name)
1252                            .map(|pkg| format!("{}@{}", dep_name, pkg.version))
1253                    })
1254                    .collect();
1255                format!("depends on: {}", dep_versions.join(", "))
1256            }
1257            _ => String::from("no workspace dependencies"),
1258        };
1259        println!("  {:>3}. {}@{} ({})", idx + 1, p.name, p.version, deps_str);
1260    }
1261    println!();
1262
1263    // Show potential issues / preflight considerations
1264    println!("=== Preflight Considerations ===");
1265    println!();
1266
1267    // Analyze potential issues
1268    let mut issues: Vec<String> = Vec::new();
1269
1270    // Check for packages with many dependencies (may take longer)
1271    for p in &ws.plan.packages {
1272        #[allow(clippy::collapsible_if)]
1273        if let Some(deps) = ws.plan.dependencies.get(&p.name) {
1274            if deps.len() > 3 {
1275                issues.push(format!(
1276                    "  - {}@{} has {} dependencies (may require longer publish time)",
1277                    p.name,
1278                    p.version,
1279                    deps.len()
1280                ));
1281            }
1282        }
1283    }
1284
1285    // Check for packages that are depended upon by many others
1286    let mut dependents_count: std::collections::HashMap<&str, usize> =
1287        std::collections::HashMap::new();
1288    for deps in ws.plan.dependencies.values() {
1289        for dep in deps {
1290            *dependents_count.entry(dep.as_str()).or_insert(0) += 1;
1291        }
1292    }
1293    for (name, count) in &dependents_count {
1294        #[allow(clippy::collapsible_if)]
1295        if *count > 3 {
1296            if let Some(pkg) = ws.plan.packages.iter().find(|p| p.name == *name) {
1297                issues.push(format!(
1298                    "  - {}@{} is a core dependency for {} packages (critical path)",
1299                    pkg.name, pkg.version, count
1300                ));
1301            }
1302        }
1303    }
1304
1305    if issues.is_empty() {
1306        println!("  No obvious issues detected.");
1307        println!("  All packages have reasonable dependency structures.");
1308    } else {
1309        for issue in &issues {
1310            println!("{}", issue);
1311        }
1312    }
1313    println!();
1314
1315    // Estimate time analysis (rough estimates)
1316    println!("=== Estimated Publishing Analysis ===");
1317    println!();
1318
1319    // Calculate max parallel packages per level
1320    let max_parallel = levels.iter().map(|l| l.packages.len()).max().unwrap_or(0);
1321    println!(
1322        "  Parallel publishing: {}",
1323        if max_parallel > 1 {
1324            "enabled"
1325        } else {
1326            "sequential"
1327        }
1328    );
1329    println!("  Max concurrent packages: {}", max_parallel);
1330    println!("  Total publish levels: {}", total_levels);
1331
1332    // Rough time estimate (assuming ~30s per package + network overhead)
1333    let total_packages = ws.plan.packages.len();
1334    let estimated_sequential_secs = total_packages * 30;
1335    let estimated_parallel_secs = levels.iter().map(|_l| 30).sum::<usize>();
1336    println!(
1337        "  Estimated time (sequential): ~{}s ({:.1}min)",
1338        estimated_sequential_secs,
1339        estimated_sequential_secs as f64 / 60.0
1340    );
1341    println!(
1342        "  Estimated time (parallel): ~{}s ({:.1}min)",
1343        estimated_parallel_secs,
1344        estimated_parallel_secs as f64 / 60.0
1345    );
1346    println!();
1347
1348    // Show final publish order
1349    println!("=== Full Publish Order ===");
1350    println!();
1351    for (idx, p) in ws.plan.packages.iter().enumerate() {
1352        let level = levels
1353            .iter()
1354            .find(|l| l.packages.iter().any(|lp| lp.name == p.name));
1355        let level_str = level
1356            .map(|l| format!("[Level {}]", l.level))
1357            .unwrap_or_else(|| "[?]".to_string());
1358        println!("  {:>3}. {} {} @{}", idx + 1, level_str, p.name, p.version);
1359    }
1360}
1361
1362fn print_preflight(rep: &PreflightReport, format: &str) {
1363    match format {
1364        "json" => {
1365            let json = serde_json::to_string_pretty(rep).expect("serialize preflight report");
1366            println!("{}", json);
1367        }
1368        _ => {
1369            println!("Preflight Report");
1370            println!("===============");
1371            println!();
1372            println!("Plan ID: {}", rep.plan_id);
1373            println!("Timestamp: {}", rep.timestamp.format("%Y-%m-%dT%H:%M:%SZ"));
1374            println!();
1375            println!(
1376                "Token Detected: {}",
1377                if rep.token_detected { "✓" } else { "✗" }
1378            );
1379            println!();
1380
1381            // Display finishability with color-coded status
1382            let (finishability_color, finishability_text) = match rep.finishability {
1383                Finishability::Proven => ("\x1b[32m", "PROVEN"),
1384                Finishability::NotProven => ("\x1b[33m", "NOT PROVEN"),
1385                Finishability::Failed => ("\x1b[31m", "FAILED"),
1386            };
1387            println!(
1388                "Finishability: {}{}",
1389                finishability_color, finishability_text
1390            );
1391            println!();
1392
1393            // Display packages in table format
1394            println!("Packages:");
1395            println!(
1396                "┌─────────────────────┬─────────┬──────────┬──────────┬───────────────┬─────────────┬─────────────┐"
1397            );
1398            println!(
1399                "│ Package             │ Version │ Published│ New Crate │ Auth Type     │ Ownership   │ Dry-run     │"
1400            );
1401            println!(
1402                "├─────────────────────┼─────────┼──────────┼──────────┼───────────────┼─────────────┼─────────────┤"
1403            );
1404            for p in &rep.packages {
1405                let published = if p.already_published { "Yes" } else { "No" };
1406                let new_crate = if p.is_new_crate { "Yes" } else { "No" };
1407                let auth_type = match p.auth_type {
1408                    Some(shipper_core::types::AuthType::Token) => "Token",
1409                    Some(shipper_core::types::AuthType::TrustedPublishing) => "Trusted",
1410                    Some(shipper_core::types::AuthType::Unknown) => "Unknown",
1411                    None => "-",
1412                };
1413                let ownership = if p.ownership_verified { "✓" } else { "✗" };
1414                let dry_run = if p.dry_run_passed { "✓" } else { "✗" };
1415
1416                println!(
1417                    "│ {:<19} │ {:<7} │ {:<8} │ {:<8} │ {:<13} │ {:<11} │ {:<11} │",
1418                    p.name, p.version, published, new_crate, auth_type, ownership, dry_run
1419                );
1420            }
1421            println!(
1422                "└─────────────────────┴─────────┴──────────┴──────────┴───────────────┴─────────────┴─────────────┘"
1423            );
1424            println!();
1425
1426            // Display dry-run failures if any
1427            let failed_packages: Vec<_> = rep
1428                .packages
1429                .iter()
1430                .filter(|p| !p.dry_run_passed && p.dry_run_output.is_some())
1431                .collect();
1432
1433            if !failed_packages.is_empty() {
1434                println!("Dry-run Failures:");
1435                println!("-----------------");
1436                for p in failed_packages {
1437                    println!("Package: {}@{}", p.name, p.version);
1438                    println!("{}", p.dry_run_output.as_ref().unwrap());
1439                    println!();
1440                }
1441            } else if rep.finishability == Finishability::Failed && rep.dry_run_output.is_some() {
1442                // Check if workspace dry-run failed
1443                println!("Workspace Dry-run Failure:");
1444                println!("--------------------------");
1445                println!("{}", rep.dry_run_output.as_ref().unwrap());
1446                println!();
1447            }
1448
1449            // Summary
1450            let total = rep.packages.len();
1451            let already_published = rep.packages.iter().filter(|p| p.already_published).count();
1452            let new_crates = rep.packages.iter().filter(|p| p.is_new_crate).count();
1453            let ownership_verified = rep.packages.iter().filter(|p| p.ownership_verified).count();
1454            let dry_run_passed = rep.packages.iter().filter(|p| p.dry_run_passed).count();
1455
1456            println!("Summary:");
1457            println!("  Total packages: {}", total);
1458            println!("  Already published: {}", already_published);
1459            println!("  New crates: {}", new_crates);
1460            println!("  Ownership verified: {}", ownership_verified);
1461            println!("  Dry-run passed: {}", dry_run_passed);
1462            println!();
1463
1464            // What to do next guidance
1465            println!("What to do next:");
1466            println!("-----------------");
1467            match rep.finishability {
1468                Finishability::Proven => {
1469                    println!(
1470                        "\x1b[32m✓ All checks passed. Ready to publish with: shipper publish\x1b[0m"
1471                    );
1472                }
1473                Finishability::NotProven => {
1474                    println!(
1475                        "\x1b[33m⚠ Some checks could not be verified. You can still publish, but may encounter permission issues. Use `shipper publish --policy fast` to proceed.\x1b[0m"
1476                    );
1477                }
1478                Finishability::Failed => {
1479                    println!(
1480                        "\x1b[31m✗ Preflight failed. Please fix the issues above before publishing.\x1b[0m"
1481                    );
1482                }
1483            }
1484        }
1485    }
1486}
1487
1488fn print_receipt(
1489    receipt: &shipper_core::types::Receipt,
1490    workspace_root: &Path,
1491    state_dir: &Path,
1492    format: &str,
1493) {
1494    match format {
1495        "json" => {
1496            let json = serde_json::to_string_pretty(receipt).expect("serialize receipt");
1497            println!("{}", json);
1498        }
1499        _ => {
1500            println!("plan_id: {}", receipt.plan_id);
1501            println!(
1502                "registry: {} ({})",
1503                receipt.registry.name, receipt.registry.api_base
1504            );
1505
1506            let abs_state = if state_dir.is_absolute() {
1507                state_dir.to_path_buf()
1508            } else {
1509                workspace_root.join(state_dir)
1510            };
1511
1512            println!(
1513                "state:   {}/{}",
1514                abs_state.display(),
1515                shipper_core::state::execution_state::STATE_FILE
1516            );
1517            println!(
1518                "receipt: {}/{}",
1519                abs_state.display(),
1520                shipper_core::state::execution_state::RECEIPT_FILE
1521            );
1522            println!(
1523                "events:   {}/{}",
1524                abs_state.display(),
1525                shipper_core::state::events::EVENTS_FILE
1526            );
1527            println!();
1528
1529            for p in &receipt.packages {
1530                println!(
1531                    "{}@{}: {:?} (attempts={}, {}ms)",
1532                    p.name, p.version, p.state, p.attempts, p.duration_ms
1533                );
1534                // Show evidence summary
1535                if !p.evidence.attempts.is_empty() {
1536                    println!("  Evidence:");
1537                    for attempt in &p.evidence.attempts {
1538                        println!(
1539                            "    Attempt {}: exit={}, duration={}ms",
1540                            attempt.attempt_number,
1541                            attempt.exit_code,
1542                            attempt.duration.as_millis()
1543                        );
1544                        if !attempt.stdout_tail.is_empty() {
1545                            println!(
1546                                "      stdout (last {} lines):",
1547                                attempt.stdout_tail.lines().count()
1548                            );
1549                            for line in attempt.stdout_tail.lines().take(5) {
1550                                println!("        {}", line);
1551                            }
1552                        }
1553                        if !attempt.stderr_tail.is_empty() {
1554                            println!(
1555                                "      stderr (last {} lines):",
1556                                attempt.stderr_tail.lines().count()
1557                            );
1558                            for line in attempt.stderr_tail.lines().take(5) {
1559                                println!("        {}", line);
1560                            }
1561                        }
1562                    }
1563                }
1564                if !p.evidence.readiness_checks.is_empty() {
1565                    println!(
1566                        "  Readiness checks: {} attempts",
1567                        p.evidence.readiness_checks.len()
1568                    );
1569                    for check in &p.evidence.readiness_checks {
1570                        println!(
1571                            "    Poll {}: visible={}, delay_before={}ms",
1572                            check.attempt,
1573                            check.visible,
1574                            check.delay_before.as_millis()
1575                        );
1576                    }
1577                }
1578            }
1579        }
1580    }
1581}
1582
1583fn run_inspect_events(ws: &plan::PlannedWorkspace, opts: &RuntimeOptions) -> Result<()> {
1584    let state_dir = if opts.state_dir.is_absolute() {
1585        opts.state_dir.clone()
1586    } else {
1587        ws.workspace_root.join(&opts.state_dir)
1588    };
1589
1590    let events_path = shipper_core::state::events::events_path(&state_dir);
1591    let event_log = shipper_core::state::events::EventLog::read_from_file(&events_path)
1592        .with_context(|| format!("failed to read event log from {}", events_path.display()))?;
1593
1594    println!("Event log: {}", events_path.display());
1595    println!();
1596
1597    for event in event_log.all_events() {
1598        let json = serde_json::to_string(event).expect("serialize event");
1599        println!("{}", json);
1600    }
1601
1602    Ok(())
1603}
1604
1605fn run_inspect_receipt(
1606    ws: &plan::PlannedWorkspace,
1607    opts: &RuntimeOptions,
1608    format: &str,
1609) -> Result<()> {
1610    let state_dir = if opts.state_dir.is_absolute() {
1611        opts.state_dir.clone()
1612    } else {
1613        ws.workspace_root.join(&opts.state_dir)
1614    };
1615
1616    let receipt_path = shipper_core::state::execution_state::receipt_path(&state_dir);
1617    let content = std::fs::read_to_string(&receipt_path)
1618        .with_context(|| format!("failed to read receipt from {}", receipt_path.display()))?;
1619
1620    let receipt: shipper_core::types::Receipt = serde_json::from_str(&content)
1621        .with_context(|| format!("failed to parse receipt from {}", receipt_path.display()))?;
1622
1623    if format == "json" {
1624        let json = serde_json::to_string_pretty(&receipt).expect("serialize receipt");
1625        println!("{}", json);
1626        return Ok(());
1627    }
1628
1629    // Display receipt in human-readable format
1630    println!("Receipt");
1631    println!("=======");
1632    println!();
1633    println!("Plan ID: {}", receipt.plan_id);
1634    println!(
1635        "Registry: {} ({})",
1636        receipt.registry.name, receipt.registry.api_base
1637    );
1638    println!(
1639        "Started: {}",
1640        receipt.started_at.format("%Y-%m-%dT%H:%M:%SZ")
1641    );
1642    println!(
1643        "Finished: {}",
1644        receipt.finished_at.format("%Y-%m-%dT%H:%M:%SZ")
1645    );
1646    println!(
1647        "Duration: {}ms",
1648        (receipt.finished_at - receipt.started_at).num_milliseconds()
1649    );
1650    println!();
1651
1652    // Display Git context if available
1653    if let Some(git) = &receipt.git_context {
1654        println!("Git Context:");
1655        println!("------------");
1656        if let Some(commit) = &git.commit {
1657            println!("  Commit: {}", commit);
1658        }
1659        if let Some(branch) = &git.branch {
1660            println!("  Branch: {}", branch);
1661        }
1662        if let Some(tag) = &git.tag {
1663            println!("  Tag: {}", tag);
1664        }
1665        if let Some(dirty) = git.dirty {
1666            println!("  Dirty: {}", if dirty { "Yes" } else { "No" });
1667        }
1668        println!();
1669    }
1670
1671    // Display environment fingerprint
1672    println!("Environment:");
1673    println!("------------");
1674    println!("  Shipper: {}", receipt.environment.shipper_version);
1675    if let Some(cargo) = &receipt.environment.cargo_version {
1676        println!("  Cargo: {}", cargo);
1677    }
1678    if let Some(rust) = &receipt.environment.rust_version {
1679        println!("  Rust: {}", rust);
1680    }
1681    println!("  OS: {}", receipt.environment.os);
1682    println!("  Arch: {}", receipt.environment.arch);
1683    println!();
1684
1685    // Display packages
1686    println!("Packages:");
1687    println!("---------");
1688    for p in &receipt.packages {
1689        let state_str = match &p.state {
1690            shipper_core::types::PackageState::Published => "\x1b[32mPublished\x1b[0m",
1691            shipper_core::types::PackageState::Pending => "Pending",
1692            shipper_core::types::PackageState::Uploaded => "\x1b[33mUploaded\x1b[0m",
1693            shipper_core::types::PackageState::Skipped { reason } => {
1694                &format!("Skipped: {}", reason)
1695            }
1696            shipper_core::types::PackageState::Failed { class, message } => {
1697                &format!("\x1b[31mFailed ({:?}): {}\x1b[0m", class, message)
1698            }
1699            shipper_core::types::PackageState::Ambiguous { message } => {
1700                &format!("\x1b[33mAmbiguous: {}\x1b[0m", message)
1701            }
1702        };
1703        println!(
1704            "  {}@{}: {} (attempts={}, {}ms)",
1705            p.name, p.version, state_str, p.attempts, p.duration_ms
1706        );
1707    }
1708
1709    Ok(())
1710}
1711
1712fn run_status(ws: &plan::PlannedWorkspace, reporter: &mut dyn Reporter) -> Result<()> {
1713    reporter.info("initializing registry client...");
1714    let reg = shipper_core::registry::RegistryClient::new(ws.plan.registry.clone())?;
1715
1716    println!("plan_id: {}", ws.plan.plan_id);
1717    println!();
1718
1719    for p in &ws.plan.packages {
1720        let exists = reg.version_exists(&p.name, &p.version)?;
1721        let status = if exists { "published" } else { "missing" };
1722        println!("{}@{}: {status}", p.name, p.version);
1723    }
1724
1725    Ok(())
1726}
1727
1728fn run_doctor(
1729    ws: &plan::PlannedWorkspace,
1730    opts: &RuntimeOptions,
1731    reporter: &mut dyn Reporter,
1732) -> Result<()> {
1733    println!("Shipper Doctor - Diagnostics Report");
1734    println!("----------------------------------");
1735    println!("workspace_root: {}", ws.workspace_root.display());
1736    println!(
1737        "registry: {} ({})",
1738        ws.plan.registry.name, ws.plan.registry.api_base
1739    );
1740
1741    // 1. Check Authentication
1742    let auth_type = shipper_core::auth::detect_auth_type(&ws.plan.registry.name)?;
1743    let auth_label = match auth_type {
1744        Some(shipper_core::types::AuthType::Token) => "token (detected)",
1745        Some(shipper_core::types::AuthType::TrustedPublishing) => "trusted (detected)",
1746        Some(shipper_core::types::AuthType::Unknown) => "unknown",
1747        None => "NONE FOUND (set CARGO_REGISTRY_TOKEN)",
1748    };
1749    println!("auth_type: {}", auth_label);
1750
1751    // 2. Check State Directory
1752    let abs_state = if opts.state_dir.is_absolute() {
1753        opts.state_dir.clone()
1754    } else {
1755        ws.workspace_root.join(&opts.state_dir)
1756    };
1757    println!("state_dir: {}", abs_state.display());
1758
1759    if abs_state.exists() {
1760        if let Ok(meta) = std::fs::metadata(&abs_state) {
1761            println!("state_dir_writable: {}", !meta.permissions().readonly());
1762        }
1763    } else {
1764        println!("state_dir_exists: false (will be created)");
1765    }
1766
1767    // 3. Check Tools
1768    println!();
1769    print_cmd_version("cargo", reporter);
1770    print_cmd_version("git", reporter);
1771
1772    // 4. Network Connectivity (Best Effort)
1773    println!();
1774    reporter.info("checking registry connectivity...");
1775    let reg_client = shipper_core::registry::RegistryClient::new(ws.plan.registry.clone())?;
1776
1777    match reg_client.crate_exists("serde") {
1778        Ok(_) => println!("registry_reachable: true"),
1779        Err(e) => reporter.warn(&format!("registry_reachable: false ({e:#})")),
1780    }
1781
1782    let index_base = ws.plan.registry.get_index_base();
1783    println!("index_base: {}", index_base);
1784
1785    // 5. Check Git State
1786    println!();
1787    match shipper_core::git::collect_git_context() {
1788        Some(git) => {
1789            println!("git_commit: {}", git.commit.unwrap_or_else(|| "-".into()));
1790            println!("git_branch: {}", git.branch.unwrap_or_else(|| "-".into()));
1791            println!("git_dirty: {}", git.dirty.unwrap_or(false));
1792        }
1793        None => println!("git_context: not a git repository"),
1794    }
1795
1796    // 6. Encryption Check
1797    if opts.encryption.enabled {
1798        println!();
1799        println!("encryption: enabled");
1800        if opts.encryption.passphrase.is_some() {
1801            println!("encryption_key_source: config");
1802        } else if let Some(ref env_var) = opts.encryption.env_var {
1803            let present = std::env::var(env_var).is_ok();
1804            println!("encryption_key_source: env ({})", env_var);
1805            println!("encryption_key_present: {}", present);
1806        }
1807    }
1808
1809    println!();
1810    println!("Diagnostics complete.");
1811
1812    Ok(())
1813}
1814
1815fn print_cmd_version(cmd: &str, reporter: &mut dyn Reporter) {
1816    let out = Command::new(cmd).arg("--version").output();
1817    match out {
1818        Ok(o) if o.status.success() => {
1819            let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
1820            println!("{cmd}: {s}");
1821        }
1822        Ok(o) => {
1823            reporter.warn(&format!(
1824                "{cmd} --version failed: {}",
1825                String::from_utf8_lossy(&o.stderr).trim()
1826            ));
1827        }
1828        Err(e) => {
1829            reporter.warn(&format!("unable to run {cmd} --version: {e}"));
1830        }
1831    }
1832}
1833
1834fn run_ci(ci_cmd: CiCommands, state_dir: &Path, workspace_root: &Path) -> Result<()> {
1835    let abs_state = if state_dir.is_absolute() {
1836        state_dir.to_path_buf()
1837    } else {
1838        workspace_root.join(state_dir)
1839    };
1840
1841    match ci_cmd {
1842        CiCommands::GitHubActions => {
1843            println!("# GitHub Actions workflow snippet for Shipper");
1844            println!("# Add these steps to your workflow file");
1845            println!();
1846            println!("# Restore Shipper State (cache for faster restores)");
1847            println!("- name: Restore Shipper State");
1848            println!("  uses: actions/cache@v3");
1849            println!("  with:");
1850            println!("    path: {}/", abs_state.display());
1851            println!("    key: shipper-${{{{ github.sha }}}}");
1852            println!("    restore-keys: |");
1853            println!("      shipper-");
1854            println!();
1855            println!("# Restore Shipper State (artifact for resumability)");
1856            println!("- name: Restore Shipper State Artifact");
1857            println!("  uses: actions/download-artifact@v4");
1858            println!("  with:");
1859            println!("    name: shipper-state");
1860            println!("    path: {}/", abs_state.display());
1861            println!("  continue-on-error: true");
1862            println!();
1863            println!("# Run shipper publish (will resume if state exists)");
1864            println!("- name: Publish Crates");
1865            println!("  run: shipper publish --quiet");
1866            println!("  env:");
1867            println!("    CARGO_REGISTRY_TOKEN: ${{{{ secrets.CARGO_REGISTRY_TOKEN }}}}");
1868            println!();
1869            println!("# Save Shipper State (even if publish fails)");
1870            println!("- name: Save Shipper State");
1871            println!("  if: always()");
1872            println!("  uses: actions/upload-artifact@v3");
1873            println!("  with:");
1874            println!("    name: shipper-state");
1875            println!("    path: {}/", abs_state.display());
1876        }
1877        CiCommands::GitLab => {
1878            println!("# GitLab CI snippet for Shipper");
1879            println!("# Add this to your .gitlab-ci.yml");
1880            println!();
1881            println!("publish:");
1882            println!("  image: rust:latest");
1883            println!("  stage: publish");
1884            println!("  cache:");
1885            println!("    key: ${{CI_COMMIT_REF_SLUG}}");
1886            println!("    paths:");
1887            println!("      - {}/", abs_state.display());
1888            println!("      - target/");
1889            println!("  script:");
1890            println!("    - cargo install shipper-cli --locked");
1891            println!("    - shipper publish --quiet");
1892            println!("  variables:");
1893            println!("    CARGO_TERM_COLOR: \"always\"");
1894            println!("    # Configure this in GitLab CI/CD settings (masked, protected)");
1895            println!("    # CARGO_REGISTRY_TOKEN: \"...\"");
1896            println!("  artifacts:");
1897            println!("    paths:");
1898            println!("      - {}/", abs_state.display());
1899            println!("    expire_in: 1 day");
1900            println!("    when: always");
1901        }
1902        CiCommands::CircleCI => {
1903            println!("# CircleCI config snippet for Shipper");
1904            println!("# Add this to your .circleci/config.yml");
1905            println!();
1906            println!("version: 2.1");
1907            println!();
1908            println!("jobs:");
1909            println!("  publish:");
1910            println!("    docker:");
1911            println!("      - image: cimg/rust:latest");
1912            println!("    steps:");
1913            println!("      - checkout");
1914            println!("      - restore_cache:");
1915            println!("          keys:");
1916            println!("            - shipper-state-{{{{ .Branch }}}}-{{{{ .Revision }}}}");
1917            println!("            - shipper-state-{{{{ .Branch }}}}");
1918            println!("            - shipper-state-");
1919            println!("      - run:");
1920            println!("          name: Install Shipper");
1921            println!("          command: cargo install shipper-cli --locked");
1922            println!("      - run:");
1923            println!("          name: Publish Crates");
1924            println!("          command: shipper publish --quiet");
1925            println!("          environment:");
1926            println!("            CARGO_REGISTRY_TOKEN: ${{{{ CARGO_REGISTRY_TOKEN }}}}");
1927            println!("      - save_cache:");
1928            println!("          key: shipper-state-{{{{ .Branch }}}}-{{{{ .Revision }}}}");
1929            println!("          paths:");
1930            println!("            - {}", abs_state.display());
1931            println!("      - store_artifacts:");
1932            println!("          path: {}", abs_state.display());
1933            println!("          destination: shipper-state");
1934            println!();
1935            println!("workflows:");
1936            println!("  version: 2");
1937            println!("  publish:");
1938            println!("    jobs:");
1939            println!("      - publish:");
1940            println!("          filters:");
1941            println!("            branches:");
1942            println!("              only: main");
1943            println!("          context: cargo-registry");
1944        }
1945        CiCommands::AzureDevOps => {
1946            println!("# Azure DevOps pipeline snippet for Shipper");
1947            println!("# Add this to your azure-pipelines.yml");
1948            println!();
1949            println!("trigger:");
1950            println!("  - main");
1951            println!();
1952            println!("pool:");
1953            println!("  vmImage: 'ubuntu-latest'");
1954            println!();
1955            println!("variables:");
1956            println!("  CARGO_HOME: $(Pipeline.Workspace)/.cargo");
1957            println!();
1958            println!("steps:");
1959            println!("  - task: Cache@2");
1960            println!("    displayName: 'Cache Cargo and Shipper State'");
1961            println!("    inputs:");
1962            println!("      key: 'shipper | \"$(Agent.OS)\" | \"$(Build.SourceVersion)\"'");
1963            println!("      restoreKeys: |");
1964            println!("        shipper | \"$(Agent.OS)\"");
1965            println!("        shipper");
1966            println!("      path: $(CARGO_HOME)");
1967            println!("      cacheHitVar: CACHE_RESTORED");
1968            println!();
1969            println!("  - script: cargo install shipper-cli --locked");
1970            println!("    displayName: 'Install Shipper'");
1971            println!();
1972            println!("  - script: shipper publish --quiet");
1973            println!("    displayName: 'Publish Crates'");
1974            println!("    env:");
1975            println!("      CARGO_REGISTRY_TOKEN: $(CARGO_REGISTRY_TOKEN)");
1976            println!();
1977            println!("  - publish: {}", abs_state.display());
1978            println!("    displayName: 'Publish Shipper State Artifact'");
1979            println!("    condition: succeededOrFailed()");
1980            println!("    artifact: 'shipper-state'");
1981        }
1982    }
1983
1984    Ok(())
1985}
1986
1987fn run_clean(
1988    state_dir: &PathBuf,
1989    workspace_root: &Path,
1990    keep_receipt: bool,
1991    force: bool,
1992) -> Result<()> {
1993    let abs_state = if state_dir.is_absolute() {
1994        state_dir.clone()
1995    } else {
1996        workspace_root.join(state_dir)
1997    };
1998
1999    if !abs_state.exists() {
2000        println!("State directory does not exist: {}", abs_state.display());
2001        return Ok(());
2002    }
2003
2004    // Identify all directories to clean (base + any registry subdirs)
2005    let mut dirs_to_clean = vec![abs_state.clone()];
2006    if let Ok(entries) = std::fs::read_dir(&abs_state) {
2007        for entry in entries.flatten() {
2008            if let Ok(file_type) = entry.file_type()
2009                && file_type.is_dir()
2010                && entry.file_name() != "cache"
2011            {
2012                dirs_to_clean.push(entry.path());
2013            }
2014        }
2015    }
2016
2017    for dir in dirs_to_clean {
2018        clean_single_dir(&dir, workspace_root, keep_receipt, force)?;
2019    }
2020
2021    println!("Clean complete");
2022    Ok(())
2023}
2024
2025fn clean_single_dir(
2026    dir: &Path,
2027    workspace_root: &Path,
2028    keep_receipt: bool,
2029    force: bool,
2030) -> Result<()> {
2031    let state_path = dir.join(shipper_core::state::execution_state::STATE_FILE);
2032    let receipt_path = dir.join(shipper_core::state::execution_state::RECEIPT_FILE);
2033    let events_path = dir.join(shipper_core::state::events::EVENTS_FILE);
2034    let lock_path = shipper_core::lock::lock_path(dir, Some(workspace_root));
2035
2036    // Check for active lock
2037    if lock_path.exists() {
2038        if force {
2039            eprintln!(
2040                "[warn] --force specified; removing lock file: {}",
2041                lock_path.display()
2042            );
2043            std::fs::remove_file(&lock_path)
2044                .with_context(|| format!("failed to remove lock file {}", lock_path.display()))?;
2045        } else {
2046            match shipper_core::lock::LockFile::read_lock_info(dir, Some(workspace_root)) {
2047                Ok(lock_info) => {
2048                    eprintln!("[warn] Active lock found in {}:", dir.display());
2049                    eprintln!("[warn]   PID: {}", lock_info.pid);
2050                    eprintln!("[warn]   Hostname: {}", lock_info.hostname);
2051                    eprintln!("[warn]   Acquired at: {}", lock_info.acquired_at);
2052                    eprintln!("[warn]   Plan ID: {:?}", lock_info.plan_id);
2053                }
2054                Err(err) => {
2055                    eprintln!(
2056                        "[warn] Active lock found in {} but metadata could not be read: {err:#}",
2057                        dir.display()
2058                    );
2059                }
2060            }
2061            eprintln!("[warn] Use --force to override the lock");
2062            bail!("cannot clean: active lock exists in {}", dir.display());
2063        }
2064    }
2065
2066    // Remove state file
2067    if state_path.exists() {
2068        std::fs::remove_file(&state_path)
2069            .with_context(|| format!("failed to remove state file {}", state_path.display()))?;
2070        println!("Removed: {}", state_path.display());
2071    }
2072
2073    // Remove events file
2074    if events_path.exists() {
2075        std::fs::remove_file(&events_path)
2076            .with_context(|| format!("failed to remove events file {}", events_path.display()))?;
2077        println!("Removed: {}", events_path.display());
2078    }
2079
2080    // Optionally remove receipt file
2081    if !keep_receipt && receipt_path.exists() {
2082        std::fs::remove_file(&receipt_path)
2083            .with_context(|| format!("failed to remove receipt file {}", receipt_path.display()))?;
2084        println!("Removed: {}", receipt_path.display());
2085    } else if keep_receipt && receipt_path.exists() {
2086        println!(
2087            "Kept: {} (--keep-receipt specified)",
2088            receipt_path.display()
2089        );
2090    }
2091
2092    // Remove cache directory if exists
2093    let cache_dir = dir.join("cache");
2094    if cache_dir.exists() {
2095        std::fs::remove_dir_all(&cache_dir)
2096            .with_context(|| format!("failed to remove cache directory {}", cache_dir.display()))?;
2097        println!("Removed: {}", cache_dir.display());
2098    }
2099
2100    Ok(())
2101}
2102
2103fn run_config(cmd: ConfigCommands) -> Result<()> {
2104    match cmd {
2105        ConfigCommands::Init { output } => {
2106            let template = ShipperConfig::default_toml_template();
2107            std::fs::write(&output, template)
2108                .with_context(|| format!("Failed to write config file to {}", output.display()))?;
2109            println!("Created configuration file: {}", output.display());
2110            println!();
2111            println!("Edit the file to customize shipper settings for your workspace.");
2112            println!("Run `shipper config validate` to check the configuration.");
2113        }
2114        ConfigCommands::Validate { path } => {
2115            if !path.exists() {
2116                bail!("Config file not found: {}", path.display());
2117            }
2118            let config = ShipperConfig::load_from_file(&path)
2119                .with_context(|| format!("Failed to load config file: {}", path.display()))?;
2120            config.validate().with_context(|| {
2121                format!("Configuration validation failed for {}", path.display())
2122            })?;
2123            println!("Configuration file is valid: {}", path.display());
2124        }
2125    }
2126    Ok(())
2127}
2128
2129fn run_completion(shell: &Shell) -> Result<()> {
2130    clap_complete::generate(
2131        *shell,
2132        &mut Cli::command(),
2133        "shipper",
2134        &mut std::io::stdout(),
2135    );
2136    Ok(())
2137}
2138
2139#[cfg(test)]
2140mod tests {
2141    use std::fs;
2142
2143    use chrono::Utc;
2144    use serial_test::serial;
2145    use tempfile::tempdir;
2146
2147    use super::*;
2148
2149    #[derive(Default)]
2150    struct TestReporter {
2151        infos: Vec<String>,
2152        warns: Vec<String>,
2153        errors: Vec<String>,
2154    }
2155
2156    impl Reporter for TestReporter {
2157        fn info(&mut self, msg: &str) {
2158            self.infos.push(msg.to_string());
2159        }
2160
2161        fn warn(&mut self, msg: &str) {
2162            self.warns.push(msg.to_string());
2163        }
2164
2165        fn error(&mut self, msg: &str) {
2166            self.errors.push(msg.to_string());
2167        }
2168    }
2169
2170    #[test]
2171    fn parse_duration_handles_valid_and_invalid_inputs() {
2172        assert!(parse_duration("1s").is_ok());
2173        assert!(parse_duration("nope").is_err());
2174    }
2175
2176    #[test]
2177    fn global_flags_parse_after_subcommand() {
2178        let cli = Cli::try_parse_from([
2179            "shipper",
2180            "preflight",
2181            "--allow-dirty",
2182            "--strict-ownership",
2183            "--verify-mode",
2184            "package",
2185            "--policy",
2186            "safe",
2187            "--format",
2188            "json",
2189        ])
2190        .expect("parse CLI");
2191
2192        assert!(matches!(cli.cmd, Commands::Preflight));
2193        assert!(cli.allow_dirty);
2194        assert!(cli.strict_ownership);
2195        assert_eq!(cli.verify_mode.as_deref(), Some("package"));
2196        assert_eq!(cli.policy.as_deref(), Some("safe"));
2197        assert_eq!(cli.format, "json");
2198    }
2199
2200    #[test]
2201    fn cli_reporter_methods_are_callable() {
2202        let mut rep = CliReporter { quiet: false };
2203        rep.info("info");
2204        rep.warn("warn");
2205        rep.error("error");
2206    }
2207
2208    #[test]
2209    fn print_cmd_version_reports_missing_command() {
2210        let mut reporter = TestReporter::default();
2211        print_cmd_version("definitely-not-a-real-command-shipper", &mut reporter);
2212        assert!(reporter.warns.iter().any(|w| w.contains("unable to run")));
2213    }
2214
2215    #[test]
2216    #[serial]
2217    fn print_cmd_version_reports_non_zero_exit() {
2218        let td = tempdir().expect("tempdir");
2219        let bin_dir = td.path().join("bin");
2220        fs::create_dir_all(&bin_dir).expect("mkdir");
2221
2222        #[cfg(windows)]
2223        let cmd_path = {
2224            let p = bin_dir.join("badver.cmd");
2225            fs::write(
2226                &p,
2227                "@echo off\r\necho bad version error 1>&2\r\nexit /b 1\r\n",
2228            )
2229            .expect("write");
2230            p
2231        };
2232
2233        #[cfg(not(windows))]
2234        let cmd_path = {
2235            use std::os::unix::fs::PermissionsExt;
2236
2237            let p = bin_dir.join("badver");
2238            fs::write(
2239                &p,
2240                "#!/usr/bin/env sh\necho bad version error >&2\nexit 1\n",
2241            )
2242            .expect("write");
2243            let mut perms = fs::metadata(&p).expect("meta").permissions();
2244            perms.set_mode(0o755);
2245            fs::set_permissions(&p, perms).expect("chmod");
2246            p
2247        };
2248
2249        let mut reporter = TestReporter::default();
2250        print_cmd_version(cmd_path.to_str().expect("utf8"), &mut reporter);
2251        assert!(
2252            reporter
2253                .warns
2254                .iter()
2255                .any(|w| w.contains("--version failed"))
2256        );
2257    }
2258
2259    #[test]
2260    fn test_reporter_collects_all_levels() {
2261        let mut reporter = TestReporter::default();
2262        reporter.info("i");
2263        reporter.warn("w");
2264        reporter.error("e");
2265        assert_eq!(reporter.infos, vec!["i".to_string()]);
2266        assert_eq!(reporter.warns, vec!["w".to_string()]);
2267        assert_eq!(reporter.errors, vec!["e".to_string()]);
2268    }
2269
2270    #[test]
2271    #[serial]
2272    fn run_doctor_supports_absolute_state_dir() {
2273        let td = tempdir().expect("tempdir");
2274        let ws = plan::PlannedWorkspace {
2275            workspace_root: td.path().to_path_buf(),
2276            plan: shipper_core::types::ReleasePlan {
2277                plan_version: "1".to_string(),
2278                plan_id: "plan-x".to_string(),
2279                created_at: chrono::Utc::now(),
2280                registry: Registry::crates_io(),
2281                packages: vec![],
2282                dependencies: std::collections::BTreeMap::new(),
2283            },
2284            skipped: vec![],
2285        };
2286
2287        let state_dir = td.path().join("abs-state");
2288        let opts = RuntimeOptions {
2289            allow_dirty: true,
2290            skip_ownership_check: true,
2291            strict_ownership: false,
2292            no_verify: false,
2293            max_attempts: 1,
2294            base_delay: Duration::from_millis(0),
2295            max_delay: Duration::from_millis(0),
2296            retry_strategy: shipper_core::retry::RetryStrategyType::Exponential,
2297            retry_jitter: 0.5,
2298            retry_per_error: shipper_core::retry::PerErrorConfig::default(),
2299            verify_timeout: Duration::from_millis(0),
2300            verify_poll_interval: Duration::from_millis(0),
2301            state_dir: state_dir.clone(),
2302            force_resume: false,
2303            force: false,
2304            lock_timeout: Duration::from_secs(3600),
2305            policy: shipper_core::types::PublishPolicy::Safe,
2306            verify_mode: shipper_core::types::VerifyMode::Workspace,
2307            readiness: shipper_core::types::ReadinessConfig::default(),
2308            output_lines: 50,
2309            parallel: shipper_core::types::ParallelConfig::default(),
2310            webhook: shipper_core::webhook::WebhookConfig::default(),
2311            encryption: shipper_core::encryption::EncryptionConfig::default(),
2312            registries: vec![],
2313            resume_from: None,
2314            rehearsal_registry: None,
2315            rehearsal_skip: false,
2316            rehearsal_smoke_install: None,
2317        };
2318
2319        fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
2320
2321        temp_env::with_vars(
2322            [
2323                ("CARGO_REGISTRY_TOKEN", None::<String>),
2324                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2325                (
2326                    "CARGO_HOME",
2327                    Some(
2328                        td.path()
2329                            .join("cargo-home")
2330                            .to_str()
2331                            .expect("utf8")
2332                            .to_string(),
2333                    ),
2334                ),
2335            ],
2336            || {
2337                let mut reporter = TestReporter::default();
2338                run_doctor(&ws, &opts, &mut reporter).expect("doctor");
2339            },
2340        );
2341    }
2342
2343    #[test]
2344    #[serial]
2345    fn run_doctor_restores_env_when_old_values_are_missing_or_present() {
2346        let td = tempdir().expect("tempdir");
2347        let ws = plan::PlannedWorkspace {
2348            workspace_root: td.path().to_path_buf(),
2349            plan: shipper_core::types::ReleasePlan {
2350                plan_version: "1".to_string(),
2351                plan_id: "plan-y".to_string(),
2352                created_at: chrono::Utc::now(),
2353                registry: Registry::crates_io(),
2354                packages: vec![],
2355                dependencies: std::collections::BTreeMap::new(),
2356            },
2357            skipped: vec![],
2358        };
2359
2360        let opts = RuntimeOptions {
2361            allow_dirty: true,
2362            skip_ownership_check: true,
2363            strict_ownership: false,
2364            no_verify: false,
2365            max_attempts: 1,
2366            base_delay: Duration::from_millis(0),
2367            max_delay: Duration::from_millis(0),
2368            retry_strategy: shipper_core::retry::RetryStrategyType::Exponential,
2369            retry_jitter: 0.5,
2370            retry_per_error: shipper_core::retry::PerErrorConfig::default(),
2371            verify_timeout: Duration::from_millis(0),
2372            verify_poll_interval: Duration::from_millis(0),
2373            state_dir: td.path().join("abs-state-2"),
2374            force_resume: false,
2375            force: false,
2376            lock_timeout: Duration::from_secs(3600),
2377            policy: shipper_core::types::PublishPolicy::Safe,
2378            verify_mode: shipper_core::types::VerifyMode::Workspace,
2379            readiness: shipper_core::types::ReadinessConfig::default(),
2380            output_lines: 50,
2381            parallel: shipper_core::types::ParallelConfig::default(),
2382            webhook: shipper_core::webhook::WebhookConfig::default(),
2383            encryption: shipper_core::encryption::EncryptionConfig::default(),
2384            registries: vec![],
2385            resume_from: None,
2386            rehearsal_registry: None,
2387            rehearsal_skip: false,
2388            rehearsal_smoke_install: None,
2389        };
2390
2391        fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
2392
2393        temp_env::with_vars(
2394            [
2395                ("CARGO_REGISTRY_TOKEN", None::<String>),
2396                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2397                (
2398                    "CARGO_HOME",
2399                    Some(
2400                        td.path()
2401                            .join("cargo-home")
2402                            .to_str()
2403                            .expect("utf8")
2404                            .to_string(),
2405                    ),
2406                ),
2407            ],
2408            || {
2409                let mut reporter = TestReporter::default();
2410                run_doctor(&ws, &opts, &mut reporter).expect("doctor");
2411            },
2412        );
2413    }
2414
2415    #[test]
2416    fn config_init_creates_file() {
2417        let td = tempdir().expect("tempdir");
2418        let config_path = td.path().join("test-config.toml");
2419
2420        run_config(ConfigCommands::Init {
2421            output: config_path.clone(),
2422        })
2423        .expect("config init should succeed");
2424
2425        assert!(config_path.exists(), "config file should be created");
2426
2427        let content = fs::read_to_string(&config_path).expect("read config file");
2428        assert!(
2429            content.contains("[policy]"),
2430            "config should contain [policy] section"
2431        );
2432        assert!(
2433            content.contains("[readiness]"),
2434            "config should contain [readiness] section"
2435        );
2436    }
2437
2438    #[test]
2439    fn config_validate_valid_file() {
2440        let td = tempdir().expect("tempdir");
2441        let config_path = td.path().join("test-config.toml");
2442
2443        // Create a valid config
2444        let valid_config = r#"
2445[policy]
2446mode = "safe"
2447
2448[verify]
2449mode = "workspace"
2450
2451[readiness]
2452enabled = true
2453method = "api"
2454initial_delay = "1s"
2455max_delay = "60s"
2456max_total_wait = "5m"
2457poll_interval = "2s"
2458jitter_factor = 0.5
2459
2460[output]
2461lines = 50
2462
2463[retry]
2464max_attempts = 6
2465base_delay = "2s"
2466max_delay = "2m"
2467
2468[lock]
2469timeout = "1h"
2470"#;
2471
2472        fs::write(&config_path, valid_config).expect("write config file");
2473
2474        run_config(ConfigCommands::Validate {
2475            path: config_path.clone(),
2476        })
2477        .expect("config validate should succeed for valid file");
2478    }
2479
2480    #[test]
2481    fn config_validate_invalid_file() {
2482        let td = tempdir().expect("tempdir");
2483        let config_path = td.path().join("test-config.toml");
2484
2485        // Create an invalid config (output_lines = 0)
2486        let invalid_config = r#"
2487[output]
2488lines = 0
2489"#;
2490
2491        fs::write(&config_path, invalid_config).expect("write config file");
2492
2493        let result = run_config(ConfigCommands::Validate {
2494            path: config_path.clone(),
2495        });
2496
2497        assert!(
2498            result.is_err(),
2499            "config validate should fail for invalid file"
2500        );
2501        let err = result.unwrap_err().to_string();
2502        // The error is wrapped in context, so check the full message
2503        assert!(
2504            err.contains("output.lines must be greater than 0")
2505                || err.contains("Configuration validation failed"),
2506            "error should mention output.lines or validation failed"
2507        );
2508    }
2509
2510    #[test]
2511    fn config_validate_missing_file() {
2512        let td = tempdir().expect("tempdir");
2513        let config_path = td.path().join("nonexistent-config.toml");
2514
2515        let result = run_config(ConfigCommands::Validate {
2516            path: config_path.clone(),
2517        });
2518
2519        assert!(
2520            result.is_err(),
2521            "config validate should fail for missing file"
2522        );
2523        let err = result.unwrap_err().to_string();
2524        assert!(
2525            err.contains("not found") || err.contains("Config file not found"),
2526            "error should mention file not found"
2527        );
2528    }
2529
2530    #[test]
2531    fn config_load_from_workspace() {
2532        let td = tempdir().expect("tempdir");
2533        let workspace_root = td.path();
2534
2535        // No config file exists
2536        let result = ShipperConfig::load_from_workspace(workspace_root);
2537        assert!(
2538            result.is_ok(),
2539            "load should succeed even without config file"
2540        );
2541        assert!(
2542            result.unwrap().is_none(),
2543            "should return None when no config exists"
2544        );
2545
2546        // Create a config file
2547        let config_path = workspace_root.join(".shipper.toml");
2548        let valid_config = r#"
2549[policy]
2550mode = "fast"
2551"#;
2552
2553        fs::write(&config_path, valid_config).expect("write config file");
2554
2555        let result = ShipperConfig::load_from_workspace(workspace_root);
2556        assert!(result.is_ok(), "load should succeed");
2557        let config = result.unwrap();
2558        assert!(config.is_some(), "should return Some when config exists");
2559        assert_eq!(
2560            config.unwrap().policy.mode,
2561            shipper_core::config::PublishPolicy::Fast
2562        );
2563    }
2564
2565    #[test]
2566    fn config_merge_with_cli_overrides() {
2567        let config = ShipperConfig {
2568            schema_version: "shipper.config.v1".to_string(),
2569            policy: shipper_core::config::PolicyConfig {
2570                mode: shipper_core::config::PublishPolicy::Safe,
2571            },
2572            verify: shipper_core::config::VerifyConfig {
2573                mode: shipper_core::config::VerifyMode::Workspace,
2574            },
2575            readiness: shipper_core::config::ReadinessConfig::default(),
2576            output: shipper_core::config::OutputConfig { lines: 100 },
2577            lock: shipper_core::config::LockConfig {
2578                timeout: Duration::from_secs(1800),
2579            },
2580            flags: shipper_core::config::FlagsConfig {
2581                allow_dirty: false,
2582                skip_ownership_check: false,
2583                strict_ownership: false,
2584            },
2585            retry: shipper_core::config::RetryConfig {
2586                policy: shipper_core::retry::RetryPolicy::Custom,
2587                max_attempts: 10,
2588                base_delay: Duration::from_secs(5),
2589                max_delay: Duration::from_secs(300),
2590                strategy: shipper_core::retry::RetryStrategyType::Exponential,
2591                jitter: 0.5,
2592                per_error: shipper_core::retry::PerErrorConfig::default(),
2593            },
2594            state_dir: None,
2595            registry: None,
2596            registries: shipper_core::config::MultiRegistryConfig::default(),
2597            parallel: shipper_core::config::ParallelConfig::default(),
2598            webhook: shipper_core::config::WebhookConfig::default(),
2599            encryption: shipper_core::config::EncryptionConfigInner::default(),
2600            storage: shipper_core::config::StorageConfigInner::default(),
2601            rehearsal: shipper_core::config::RehearsalConfig::default(),
2602        };
2603
2604        // CLI overrides some values, leaves others as None
2605        let cli = CliOverrides {
2606            allow_dirty: true,
2607            max_attempts: Some(3),
2608            output_lines: Some(50),
2609            policy: Some(shipper_core::config::PublishPolicy::Fast),
2610            verify_mode: Some(shipper_core::config::VerifyMode::None),
2611            ..Default::default()
2612        };
2613
2614        let merged: RuntimeOptions = config.build_runtime_options(cli);
2615
2616        // CLI values should win where set
2617        assert!(merged.allow_dirty, "CLI allow_dirty should win");
2618        assert_eq!(merged.max_attempts, 3, "CLI max_attempts should win");
2619        assert_eq!(merged.output_lines, 50, "CLI output_lines should win");
2620        assert_eq!(
2621            merged.policy,
2622            shipper_core::types::PublishPolicy::Fast,
2623            "CLI policy should win"
2624        );
2625        assert_eq!(
2626            merged.verify_mode,
2627            shipper_core::types::VerifyMode::None,
2628            "CLI verify_mode should win"
2629        );
2630
2631        // Config values should apply where CLI is None
2632        assert_eq!(
2633            merged.base_delay,
2634            Duration::from_secs(5),
2635            "config base_delay should apply"
2636        );
2637        assert_eq!(
2638            merged.max_delay,
2639            Duration::from_secs(300),
2640            "config max_delay should apply"
2641        );
2642        assert_eq!(
2643            merged.lock_timeout,
2644            Duration::from_secs(1800),
2645            "config lock_timeout should apply"
2646        );
2647    }
2648
2649    #[test]
2650    fn run_clean_errors_when_lock_exists_without_force() {
2651        let td = tempdir().expect("tempdir");
2652        let state_dir = PathBuf::from(".shipper");
2653        let abs_state = td.path().join(&state_dir);
2654        fs::create_dir_all(&abs_state).expect("mkdir");
2655
2656        let lock_info = shipper_core::lock::LockInfo {
2657            pid: 12345,
2658            hostname: "test-host".to_string(),
2659            acquired_at: Utc::now(),
2660            plan_id: Some("plan-123".to_string()),
2661        };
2662        let lock_path = shipper_core::lock::lock_path(&abs_state, Some(td.path()));
2663        fs::write(
2664            &lock_path,
2665            serde_json::to_string(&lock_info).expect("serialize"),
2666        )
2667        .expect("write lock");
2668
2669        let err = run_clean(&state_dir, td.path(), false, false).expect_err("must fail");
2670        assert!(err.to_string().contains("cannot clean: active lock exists"));
2671        assert!(lock_path.exists());
2672    }
2673
2674    #[test]
2675    fn run_clean_force_removes_lock_and_state_files() {
2676        let td = tempdir().expect("tempdir");
2677        let state_dir = PathBuf::from(".shipper");
2678        let abs_state = td.path().join(&state_dir);
2679        fs::create_dir_all(&abs_state).expect("mkdir");
2680
2681        let state_path = abs_state.join(shipper_core::state::execution_state::STATE_FILE);
2682        let receipt_path = abs_state.join(shipper_core::state::execution_state::RECEIPT_FILE);
2683        let events_path = abs_state.join(shipper_core::state::events::EVENTS_FILE);
2684        let lock_path = shipper_core::lock::lock_path(&abs_state, Some(td.path()));
2685
2686        fs::write(&state_path, "{}").expect("write state");
2687        fs::write(&receipt_path, "{}").expect("write receipt");
2688        fs::write(&events_path, "{}").expect("write events");
2689
2690        let lock_info = shipper_core::lock::LockInfo {
2691            pid: 12345,
2692            hostname: "test-host".to_string(),
2693            acquired_at: Utc::now(),
2694            plan_id: Some("plan-123".to_string()),
2695        };
2696        fs::write(
2697            &lock_path,
2698            serde_json::to_string(&lock_info).expect("serialize"),
2699        )
2700        .expect("write lock");
2701
2702        run_clean(&state_dir, td.path(), false, true).expect("clean with force");
2703
2704        assert!(!state_path.exists(), "state file should be removed");
2705        assert!(!receipt_path.exists(), "receipt file should be removed");
2706        assert!(!events_path.exists(), "events file should be removed");
2707        assert!(!lock_path.exists(), "lock file should be removed");
2708    }
2709}