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`]. This crate also ships its own `shipper-cli` binary,
17//! another three-line wrapper over [`run`], for callers that want the
18//! adapter crate installed directly (`cargo install shipper-cli`) or
19//! for workspace-local development.
20//!
21//! ## Embedding
22//!
23//! Most callers should use the `shipper` CLI directly. If you need to
24//! embed the exact CLI surface in another Rust program — for example,
25//! a wrapper that invokes `shipper` with extra preflight steps — call
26//! [`run`]. For programmatic use without a `clap` dependency, depend
27//! on [`shipper_core`](https://crates.io/crates/shipper-core) instead.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
31use std::path::{Path, PathBuf};
32use std::time::Duration;
33
34use anyhow::{Context, Result, bail};
35use clap::{CommandFactory, Parser, Subcommand};
36use clap_complete::Shell;
37use serde::Serialize;
38
39use shipper_core::config::{CliOverrides, ShipperConfig};
40use shipper_core::engine::{self, Reporter};
41use shipper_core::plan;
42use shipper_core::runtime::execution::pkg_key;
43use shipper_core::types::{
44    EventType, ExecutionState, Finishability, PackageState, PlannedPackage, PreflightPackage,
45    PreflightReport, PublishEvent, Registry, ReleasePlan, ReleaseSpec, RuntimeOptions,
46};
47
48mod doctor;
49mod output;
50
51use crate::output::progress::ProgressReporter;
52
53/// Extra build metadata shown by `shipper --version --verbose`.
54///
55/// Format:
56/// ```text
57/// commit: abc1234
58/// build:  release
59/// rustc:  rustc 1.92.0 (... )
60/// ```
61const RICH_VERSION_DETAILS: &str = concat!(
62    "commit: ",
63    env!("SHIPPER_GIT_SHA"),
64    "\nbuild:  ",
65    env!("SHIPPER_BUILD_PROFILE"),
66    "\nrustc:  ",
67    env!("SHIPPER_RUSTC_VERSION"),
68);
69
70#[derive(Parser, Debug)]
71#[command(name = "shipper", version, disable_version_flag = true)]
72#[command(about = "Resumable, backoff-aware crates.io publishing for workspaces")]
73#[command(override_usage = "shipper [OPTIONS] <COMMAND>")]
74struct Cli {
75    /// Print version information. Combine with `--verbose` for commit,
76    /// build-profile, and rustc metadata.
77    #[arg(short = 'V', long = "version", global = true)]
78    version: bool,
79
80    /// Path to a custom configuration file (.shipper.toml)
81    #[arg(long, global = true)]
82    config: Option<PathBuf>,
83
84    /// Path to the workspace Cargo.toml
85    #[arg(long, default_value = "Cargo.toml", global = true)]
86    manifest_path: PathBuf,
87
88    /// Cargo registry name (default: crates-io)
89    #[arg(long, global = true)]
90    registry: Option<String>,
91
92    /// Registry API base URL (default: <https://crates.io>)
93    #[arg(long, global = true)]
94    api_base: Option<String>,
95
96    /// Restrict to specific packages (repeatable). If omitted, publishes all publishable workspace members.
97    #[arg(long = "package", global = true)]
98    packages: Vec<String>,
99
100    /// Directory for shipper state and receipts (default: .shipper)
101    #[arg(long, global = true)]
102    state_dir: Option<PathBuf>,
103
104    /// Number of output lines to capture for evidence (default: 50)
105    #[arg(long, global = true)]
106    output_lines: Option<usize>,
107
108    /// Allow publishing from a dirty git working tree.
109    #[arg(long, global = true)]
110    allow_dirty: bool,
111
112    /// Skip owners/permissions preflight.
113    #[arg(long, global = true)]
114    skip_ownership_check: bool,
115
116    /// Fail preflight if ownership checks fail or if no token is available.
117    ///
118    /// Note: crates.io token scopes may not allow querying owners; this is best-effort.
119    #[arg(long, global = true)]
120    strict_ownership: bool,
121
122    /// Pass --no-verify to cargo publish.
123    #[arg(long, global = true)]
124    no_verify: bool,
125
126    /// Max attempts per crate publish step (default: 6)
127    #[arg(long, global = true)]
128    max_attempts: Option<u32>,
129
130    /// Base backoff delay (e.g. 2s, 500ms; default: 2s)
131    #[arg(long, global = true)]
132    base_delay: Option<String>,
133
134    /// Max backoff delay (e.g. 2m; default: 2m)
135    #[arg(long, global = true)]
136    max_delay: Option<String>,
137
138    /// Retry strategy: immediate, exponential (default), linear, constant
139    #[arg(long, global = true)]
140    retry_strategy: Option<String>,
141
142    /// Jitter factor for retry delays (0.0 = no jitter, 1.0 = full jitter; default: 0.5)
143    #[arg(long, global = true)]
144    retry_jitter: Option<f64>,
145
146    /// How long to wait for registry visibility after a successful publish (default: 2m)
147    #[arg(long, global = true)]
148    verify_timeout: Option<String>,
149
150    /// Poll interval for checking registry visibility (default: 5s)
151    #[arg(long, global = true)]
152    verify_poll: Option<String>,
153
154    /// Readiness check method: api (default, fast), index (slower, more accurate), both (slowest, most reliable)
155    #[arg(long, global = true)]
156    readiness_method: Option<String>,
157
158    /// How long to wait for registry visibility during readiness checks (default: 5m)
159    #[arg(long, global = true)]
160    readiness_timeout: Option<String>,
161
162    /// Poll interval for readiness checks (default: 2s)
163    #[arg(long, global = true)]
164    readiness_poll: Option<String>,
165
166    /// Disable readiness checks (for advanced users).
167    #[arg(long, global = true)]
168    no_readiness: bool,
169
170    /// Force resume even if the computed plan differs from the state file.
171    #[arg(long, global = true)]
172    force_resume: bool,
173
174    /// Force override of existing locks (use with caution)
175    #[arg(long, global = true)]
176    force: bool,
177
178    /// Lock timeout duration (e.g. 1h, 30m; default: 1h). Locks older than this are considered stale.
179    #[arg(long, global = true)]
180    lock_timeout: Option<String>,
181
182    /// Publish policy: safe (verify+strict), balanced (verify when needed), fast (no verify; default: safe)
183    #[arg(long, global = true)]
184    policy: Option<String>,
185
186    /// Verify mode: workspace (default), package (per-crate), none (no verify)
187    #[arg(long, global = true)]
188    verify_mode: Option<String>,
189
190    /// Enable parallel publishing (packages at the same dependency level are published concurrently)
191    #[arg(long, global = true)]
192    parallel: bool,
193
194    /// Maximum number of concurrent publish operations (implies --parallel)
195    #[arg(long, global = true)]
196    max_concurrent: Option<usize>,
197
198    /// Timeout per package publish operation when using parallel mode (e.g. 30m, 1h)
199    #[arg(long, global = true)]
200    per_package_timeout: Option<String>,
201
202    /// Webhook URL to send publish event notifications to
203    #[arg(long, global = true)]
204    webhook_url: Option<String>,
205
206    /// Optional secret for signing webhook payloads
207    #[arg(long, global = true)]
208    webhook_secret: Option<String>,
209
210    /// Enable encryption for state files
211    #[arg(long, global = true)]
212    encrypt: bool,
213
214    /// Passphrase for state file encryption (or use SHIPPER_ENCRYPT_KEY env var)
215    #[arg(long, global = true)]
216    encrypt_passphrase: Option<String>,
217
218    /// Target registries for multi-registry publishing (comma-separated list)
219    /// Example: --registries crates-io,my-registry
220    #[arg(long, global = true)]
221    registries: Option<String>,
222
223    /// Publish to all configured registries
224    #[arg(long, global = true)]
225    all_registries: bool,
226
227    /// Optional package name to resume from
228    #[arg(long, global = true)]
229    resume_from: Option<String>,
230
231    /// Name of a registry (from `[[registries]]` in `.shipper.toml`) to
232    /// rehearse the publish against before live dispatch.
233    ///
234    /// See issue #97. Plumbed through today; phase-2 execution (actual
235    /// publish to the rehearsal registry + install/smoke checks + live
236    /// dispatch gate) lands in a follow-on PR.
237    #[arg(long, global = true)]
238    rehearsal_registry: Option<String>,
239
240    /// Skip rehearsal even if `.shipper.toml` enables it.
241    ///
242    /// Use with caution — rehearsal (once fully implemented under #97)
243    /// is the proof boundary between "we built it" and "we verified it
244    /// actually resolves from a registry." Bypassing it should be rare.
245    #[arg(long, global = true)]
246    skip_rehearsal: bool,
247
248    /// Crate name to smoke-install after a successful rehearsal (#97 PR 4).
249    ///
250    /// Runs `cargo install --registry <rehearsal> <CRATE>` against the
251    /// rehearsal registry to prove the crate actually resolves and
252    /// installs end-to-end — the scenario that workspace-path
253    /// dependencies defeat and that killed the rc.1 first-publish.
254    ///
255    /// The named crate must be in the plan AND have a `[[bin]]` target.
256    /// Library-only crates cannot be smoke-installed directly; use a
257    /// consumer-workspace build instead (follow-on).
258    #[arg(long = "smoke-install", global = true, value_name = "CRATE")]
259    rehearsal_smoke_install: Option<String>,
260
261    /// Output format: text (default) or json
262    #[arg(long, default_value = "text", value_parser = ["text", "json"], global = true)]
263    format: String,
264
265    /// Show detailed dependency analysis for plan command
266    #[arg(long, global = true)]
267    verbose: bool,
268
269    /// Suppress informational output
270    #[arg(short, long, global = true)]
271    quiet: bool,
272
273    #[command(subcommand)]
274    cmd: Option<Commands>,
275}
276
277#[derive(Subcommand, Debug)]
278enum Commands {
279    /// Print the deterministic publish plan (dependency-first ordering).
280    #[command(long_about = "\
281Print the deterministic publish plan (dependency-first ordering).
282
283Reads the workspace via `cargo metadata`, filters publishable crates,
284topologically sorts them, and prints the order in which they will be
285published. The plan is deterministic — the same workspace produces the
286same plan ID on any machine — which is the anchor that makes `resume`
287safe.
288
289EXAMPLES:
290    # Preview the publish order for every publishable workspace member:
291    shipper plan
292
293    # Plan with dependency-level breakdown (who can publish in parallel):
294    shipper plan --verbose
295")]
296    Plan,
297    /// Run preflight checks without publishing.
298    #[command(long_about = "\
299Run preflight checks without publishing.
300
301Validates everything that can fail a live publish — git cleanliness,
302registry reachability, token availability, dry-run, ownership — and
303prints a `Finishability` verdict (PROVEN / NOT PROVEN / FAILED). No
304crate is uploaded. Run this before `publish` on any run you cannot
305afford to restart from scratch.
306
307EXAMPLES:
308    # Run preflight across the whole workspace:
309    shipper preflight
310
311    # Machine-readable output for CI gates:
312    shipper preflight --format json
313")]
314    Preflight {
315        /// Run preflight as a standalone audit.
316        ///
317        /// Writes events to a session-scoped
318        /// `preflight-only-<session>.events.jsonl` sidecar under `state_dir`,
319        /// does not append to the authoritative `events.jsonl`, and never
320        /// writes publish state (`state.json`). Use this when you want a fresh
321        /// Proven/NotProven/Failed signal without mutating resumable publish
322        /// state. Part of
323        /// [#100 Prove](https://github.com/EffortlessMetrics/shipper/issues/100).
324        #[arg(long = "preflight-only")]
325        preflight_only: bool,
326    },
327    /// Execute the plan (will resume if a matching state file exists).
328    #[command(long_about = "\
329Execute the publish plan end-to-end, persisting resumable state after
330every step.
331
332If `.shipper/state.json` already exists for this plan, `publish` picks
333up where the previous run left off — already-published crates are
334skipped, and the run continues from the first pending or failed
335package. On interruption (Ctrl-C, network drop, ambiguous registry
336response), rerun `shipper publish` or `shipper resume`.
337
338EXAMPLES:
339    # Publish the whole workspace to crates.io:
340    shipper publish
341
342    # Publish a subset, allowing a dirty git tree (local rehearsal):
343    shipper publish --package shipper-core --allow-dirty
344")]
345    Publish,
346    /// Resume a previous publish run.
347    #[command(long_about = "\
348Resume a previous publish run.
349
350Loads `.shipper/state.json`, validates it against the current plan, skips
351already-published packages, and continues from the first pending or failed
352package. Use this after a killed runner, network interruption, or manual
353stop.
354
355EXAMPLES:
356    # Continue the current workspace release from persisted state:
357    shipper resume
358
359    # Resume from a specific crate after reviewing the saved state:
360    shipper resume --resume-from shipper-core
361
362    # Force resume when the computed plan differs from saved state:
363    shipper resume --force-resume
364")]
365    Resume,
366    /// Rehearse a release against an alternate registry (#97 PR 2).
367    ///
368    /// Publishes every crate in the plan to the registry named by
369    /// `--rehearsal-registry` (or `[rehearsal] registry = "..."` in
370    /// `.shipper.toml`), verifies visibility on that registry, and
371    /// emits a `RehearsalComplete { passed, ... }` event to
372    /// `events.jsonl` so the outcome is auditable.
373    ///
374    /// Rehearse must target a non-live registry (kellnr, a sandbox
375    /// crates.io account, or a throwaway alternate registry). Shipper
376    /// refuses to rehearse against the same registry as the live target.
377    ///
378    /// Part of [#97](https://github.com/EffortlessMetrics/shipper/issues/97).
379    /// The hard gate that blocks live publish without a passing rehearsal
380    /// lands in #97 PR 3.
381    Rehearse,
382    /// Compare local workspace versions to the registry.
383    #[command(long_about = "\
384Compare local workspace versions to the registry.
385
386Use status before publish or after an interruption to see which local crate
387versions already exist on the target registry. This is a read-only registry
388comparison and does not mutate `.shipper/` state.
389
390EXAMPLES:
391    # Check every publishable workspace member:
392    shipper status
393
394    # Check one package against the configured registry:
395    shipper status --package shipper-core
396
397    # Watch persisted release progress while publish or resume is running:
398    shipper status --watch
399")]
400    Status {
401        /// Watch local `.shipper/` state and events until interrupted.
402        ///
403        /// Watch mode is read-only and does not poll the registry. It summarizes
404        /// `state.json` and `events.jsonl` so operators can see current progress,
405        /// the last durable event, and the next scheduled wait/retry/poll.
406        #[arg(long)]
407        watch: bool,
408    },
409    /// Print environment and auth diagnostics.
410    #[command(long_about = "\
411Print environment and auth diagnostics.
412
413Checks local tools, registry reachability, authentication signals, workspace
414health, and state-directory basics. Run this first when preflight or publish
415reports an environment blocker.
416
417EXAMPLES:
418    # Inspect local release prerequisites:
419    shipper doctor
420
421    # Check a named Cargo registry:
422    shipper doctor --registry crates-io
423")]
424    Doctor,
425    /// View detailed event log.
426    #[command(long_about = "\
427View the authoritative event log.
428
429Reads `<state-dir>/events.jsonl`, which is the truth source for publish and
430resume state transitions. Use `--follow` while another terminal is running
431publish or resume.
432
433EXAMPLES:
434    # Print the current event log:
435    shipper inspect-events
436
437    # Follow appended events during a release:
438    shipper inspect-events --follow
439")]
440    InspectEvents {
441        /// Follow the authoritative events.jsonl and print appended events as they arrive.
442        #[arg(long)]
443        follow: bool,
444    },
445    /// View detailed receipt with evidence.
446    #[command(long_about = "\
447View the end-of-run receipt with evidence.
448
449Reads `<state-dir>/receipt.json` and prints the completed release summary,
450package outcomes, git context, environment fingerprint, and captured evidence.
451
452EXAMPLES:
453    # Print the human-readable release receipt:
454    shipper inspect-receipt
455
456    # Emit the receipt in JSON for CI or an internal developer portal:
457    shipper inspect-receipt --format json
458")]
459    InspectReceipt,
460    /// Print CI configuration snippets for various platforms.
461    #[command(subcommand)]
462    Ci(CiCommands),
463    /// Clean state files (state.json, receipt.json, events.jsonl, preflight-only-*.events.jsonl).
464    Clean {
465        /// Keep receipt.json (remove state.json and all event logs only)
466        #[arg(long)]
467        keep_receipt: bool,
468    },
469    /// Yank a crate@version from the registry — containment, not undo.
470    ///
471    /// `cargo yank` marks a specific version as not-installable for NEW
472    /// dependency resolves. Existing lockfile pins and already-downloaded
473    /// copies are unaffected. See
474    /// [cargo yank docs](https://doc.rust-lang.org/cargo/commands/cargo-yank.html).
475    ///
476    /// Part of [#98 Remediate](https://github.com/EffortlessMetrics/shipper/issues/98).
477    /// Follow-on commands (`shipper plan-yank`, `shipper fix-forward`)
478    /// compose this primitive into reverse-topological containment and
479    /// fix-forward plans.
480    Yank {
481        /// Name of the crate to yank (e.g., `shipper-types`). Required
482        /// unless `--plan` is supplied.
483        #[arg(long = "crate", value_name = "NAME", conflicts_with = "plan")]
484        crate_name: Option<String>,
485        /// Version to yank (e.g., `1.2.3`). Required unless `--plan`
486        /// is supplied.
487        #[arg(long, value_name = "VERSION", conflicts_with = "plan")]
488        version: Option<String>,
489        /// Operator-supplied reason. Required unless `--plan` is supplied.
490        /// Recorded in the event log, audit trails, and any future
491        /// receipts that reference this yank.
492        ///
493        /// Example: `"CVE-2026-0001 disclosed; containing while patch
494        /// released"`.
495        #[arg(long, conflicts_with = "plan")]
496        reason: Option<String>,
497        /// Also mark the crate's existing receipt entry as compromised
498        /// (#98 PR 3). Ignored in `--plan` mode (plan execution already
499        /// carries per-entry reasons from the planning step).
500        #[arg(long)]
501        mark_compromised: bool,
502        /// **Plan execution mode** (#98 PR 5). Path to a yank plan JSON
503        /// file (the `--format json` output of `shipper plan-yank`).
504        /// Walks the plan's entries in order, invoking `cargo yank` for
505        /// each. Mutually exclusive with `--crate` / `--version` /
506        /// `--reason`.
507        #[arg(long, value_name = "PATH")]
508        plan: Option<PathBuf>,
509    },
510    /// Generate a reverse-topological yank plan from a receipt (#98 PR 2).
511    ///
512    /// Reads a prior `receipt.json` and emits the order in which to yank
513    /// the released crates — dependents first, dependencies last — so
514    /// downstream consumers stop resolving against the bad version before
515    /// the bad version itself is pulled. Output is either human-readable
516    /// `shipper yank ...` lines or structured JSON for scripting.
517    ///
518    /// **Planning only.** This command does NOT execute yanks. Pipe the
519    /// output through `sh`, or consume the JSON, once you've reviewed it.
520    /// `shipper fix-forward` (#98 PR 3) will wrap execution.
521    PlanYank {
522        /// Path to the receipt to derive the plan from. Defaults to
523        /// `<state_dir>/receipt.json` when omitted.
524        #[arg(long, value_name = "PATH")]
525        from_receipt: Option<PathBuf>,
526        /// Restrict the plan to packages whose receipt carries a
527        /// `compromised_at` marker. Without this, every `Published`
528        /// package is included (full rollback). Mutually exclusive
529        /// with `--starting-crate`.
530        #[arg(long, conflicts_with = "starting_crate")]
531        compromised_only: bool,
532        /// **Graph mode** (#98 PR 4). Given a specific broken crate
533        /// name, walk the workspace's dependency graph to find every
534        /// crate that transitively depends on it, and emit a yank
535        /// plan covering only that affected chain (not a full
536        /// rollback). Resolves the graph from the current workspace's
537        /// `Cargo.toml` metadata — the receipt supplies the versions
538        /// and Published-state filter.
539        #[arg(long, value_name = "CRATE")]
540        starting_crate: Option<String>,
541        /// Per-entry reason to embed in the yank plan (applied to
542        /// every entry). If omitted, each entry's reason falls back
543        /// to its receipt-level `compromised_by` field (if set).
544        #[arg(long, value_name = "REASON")]
545        reason: Option<String>,
546    },
547    /// Generate a fix-forward supersession plan from a compromised
548    /// receipt (#98 PR 3).
549    ///
550    /// Reads a prior `receipt.json`, finds packages whose receipt entry
551    /// carries a `compromised_at` marker (populated by
552    /// `shipper yank ... --mark-compromised`), and prints an ordered
553    /// list of successor versions to publish. Dependencies go first
554    /// (opposite of plan-yank) so downstream consumers can upgrade to a
555    /// clean chain on `cargo update`.
556    ///
557    /// **Planning only.** This command does NOT edit Cargo.toml or
558    /// invoke publish — that's operator territory. It prints the
559    /// steps, you execute them.
560    #[command(name = "fix-forward")]
561    FixForward {
562        /// Path to the compromised receipt. Defaults to
563        /// `<state_dir>/receipt.json` when omitted.
564        #[arg(long, value_name = "PATH")]
565        from_receipt: Option<PathBuf>,
566    },
567    /// Generate or execute a receipt-driven remediation plan.
568    ///
569    /// In `--dry-run` mode, reads a prior `receipt.json`, targets a
570    /// specific bad crate version, computes the affected reverse-topological
571    /// yank order and publish-directional fix-forward suggestions, then
572    /// writes `<state_dir>/remediation-plan.json` for operator review.
573    ///
574    /// In `--execute-plan` mode, reads a reviewed remediation plan and invokes
575    /// only the containment yanks in the recorded order. It does not edit
576    /// manifests or publish fix-forward successors.
577    #[command(
578        name = "remediate",
579        long_about = "\
580Generate or execute a receipt-driven remediation plan.
581
582In `--dry-run` mode, reads a prior `receipt.json`, targets a specific bad
583crate version, computes the affected reverse-topological yank order and
584publish-directional fix-forward suggestions, then writes
585`<state-dir>/remediation-plan.json` for operator review and agent consumption.
586
587In `--execute-plan` mode, consumes that reviewed artifact and executes only the
588recorded containment yanks. It does NOT edit manifests or publish fix-forward
589successors.
590
591EXAMPLES:
592    shipper remediate --dry-run --from-receipt .shipper/receipt.json --crate bad-crate --target-version 0.4.0 --reason \"CVE-2026-0001\"
593    shipper remediate --execute-plan .shipper/remediation-plan.json
594"
595    )]
596    Remediate {
597        /// Path to the receipt to derive the remediation plan from. Defaults
598        /// to `<state_dir>/receipt.json` when omitted.
599        #[arg(long, value_name = "PATH", conflicts_with = "execute_plan")]
600        from_receipt: Option<PathBuf>,
601        /// Bad crate name to contain and fix-forward.
602        #[arg(
603            long = "crate",
604            value_name = "NAME",
605            required_unless_present = "execute_plan",
606            conflicts_with = "execute_plan"
607        )]
608        crate_name: Option<String>,
609        /// Bad crate version in the source receipt.
610        #[arg(
611            long = "target-version",
612            value_name = "VERSION",
613            required_unless_present = "execute_plan",
614            conflicts_with = "execute_plan"
615        )]
616        target_version: Option<String>,
617        /// Operator-supplied reason recorded in the artifact and command list.
618        #[arg(
619            long,
620            value_name = "REASON",
621            required_unless_present = "execute_plan",
622            conflicts_with = "execute_plan"
623        )]
624        reason: Option<String>,
625        /// Required for now: generate the remediation artifact without
626        /// executing yanks, editing manifests, or publishing successors.
627        #[arg(long, conflicts_with = "execute_plan")]
628        dry_run: bool,
629        /// Execute a reviewed remediation plan artifact. Runs only the
630        /// recorded containment yanks and halts on the first failed yank.
631        #[arg(long = "execute-plan", value_name = "PATH")]
632        execute_plan: Option<PathBuf>,
633    },
634    /// Configuration file management.
635    #[command(subcommand)]
636    Config(ConfigCommands),
637    /// Generate shell completion scripts for the specified shell.
638    Completion {
639        /// Shell to generate completions for.
640        #[arg(value_enum)]
641        shell: Shell,
642    },
643}
644
645#[derive(Subcommand, Debug)]
646enum CiCommands {
647    /// Print GitHub Actions workflow snippet.
648    #[command(name = "github-actions")]
649    GitHubActions,
650    /// Print GitLab CI workflow snippet.
651    #[command(name = "gitlab")]
652    GitLab,
653    /// Print CircleCI workflow snippet.
654    #[command(name = "circleci")]
655    CircleCI,
656    /// Print Azure DevOps pipeline snippet.
657    #[command(name = "azure-devops")]
658    AzureDevOps,
659}
660
661#[derive(Subcommand, Debug, Clone)]
662enum ConfigCommands {
663    /// Generate a default .shipper.toml configuration file.
664    Init {
665        /// Output path for the configuration file (default: .shipper.toml)
666        #[arg(short, long, default_value = ".shipper.toml")]
667        output: PathBuf,
668    },
669    /// Validate a configuration file.
670    Validate {
671        /// Path to the configuration file to validate (default: .shipper.toml)
672        #[arg(short, long, default_value = ".shipper.toml")]
673        path: PathBuf,
674    },
675}
676
677struct CliReporter {
678    quiet: bool,
679    /// Optional progress handle installed during `publish`/`resume` so
680    /// [`CliReporter::retry_wait`] can render a live countdown via the
681    /// existing `ProgressReporter::retry_countdown`. When `None`, retries
682    /// fall through to the default `Reporter::retry_wait` behavior (warn +
683    /// sleep), matching subcommands that don't own a progress bar.
684    progress: Option<ProgressReporter>,
685    package_positions: BTreeMap<String, usize>,
686}
687
688impl CliReporter {
689    fn new(quiet: bool) -> Self {
690        Self {
691            quiet,
692            progress: None,
693            package_positions: BTreeMap::new(),
694        }
695    }
696
697    fn install_progress(
698        &mut self,
699        progress: ProgressReporter,
700        package_positions: BTreeMap<String, usize>,
701    ) {
702        self.progress = Some(progress);
703        self.package_positions = package_positions;
704    }
705
706    fn take_progress(&mut self) -> Option<ProgressReporter> {
707        self.package_positions.clear();
708        self.progress.take()
709    }
710}
711
712impl Reporter for CliReporter {
713    fn info(&mut self, msg: &str) {
714        if !self.quiet {
715            eprintln!("[info] {msg}");
716        }
717    }
718
719    fn warn(&mut self, msg: &str) {
720        if !self.quiet {
721            eprintln!("[warn] {msg}");
722        }
723    }
724
725    fn error(&mut self, msg: &str) {
726        eprintln!("[error] {msg}");
727    }
728
729    #[allow(clippy::too_many_arguments)]
730    fn retry_wait(
731        &mut self,
732        pkg_name: &str,
733        pkg_version: &str,
734        attempt: u32,
735        max_attempts: u32,
736        delay: std::time::Duration,
737        reason: shipper_core::types::ErrorClass,
738        message: &str,
739    ) {
740        // If a progress handle is installed (publish/resume flow), route the
741        // retry narration through it so TTY mode gets a live countdown and
742        // non-TTY mode gets a single line. Otherwise fall back to the
743        // default trait impl so callers without a progress bar still see the
744        // original warn-line behavior.
745        if let Some(progress) = &mut self.progress {
746            if let Some(index) = self
747                .package_positions
748                .get(&format!("{pkg_name}@{pkg_version}"))
749            {
750                progress.set_package(*index, pkg_name, pkg_version);
751            }
752            progress.retry_countdown(
753                pkg_name,
754                pkg_version,
755                attempt,
756                max_attempts,
757                delay,
758                &format!("{reason:?}"),
759                message,
760            );
761        } else if !self.quiet {
762            eprintln!(
763                "[warn] {pkg_name}@{pkg_version}: {message} ({reason:?}); next attempt in {} (attempt {}/{})",
764                humantime::format_duration(delay),
765                attempt.saturating_add(1),
766                max_attempts,
767            );
768            std::thread::sleep(delay);
769        } else {
770            std::thread::sleep(delay);
771        }
772    }
773}
774
775/// CLI entry point. Exposed for the `shipper` crate's binary target
776/// and for the `shipper-cli` crate's own `shipper-cli` binary — both
777/// are three-line `fn main() { shipper_cli::run() }` wrappers over
778/// this function.
779pub fn run() -> Result<()> {
780    let cli = Cli::parse();
781
782    if cli.version {
783        print_version(cli.verbose);
784        return Ok(());
785    }
786
787    // Handle Config commands early (they don't need workspace plan)
788    if let Some(Commands::Config(config_cmd)) = &cli.cmd {
789        return run_config(config_cmd.clone());
790    }
791
792    // Handle Completion commands early (they don't need workspace plan)
793    if let Some(Commands::Completion { shell }) = &cli.cmd {
794        return run_completion(shell);
795    }
796
797    if cli.cmd.is_none() {
798        Cli::command()
799            .error(
800                clap::error::ErrorKind::MissingSubcommand,
801                "'shipper' requires a subcommand but one was not provided",
802            )
803            .exit();
804    }
805
806    let api_base = cli
807        .api_base
808        .clone()
809        .unwrap_or_else(|| "https://crates.io".to_string());
810    let index_base = cli.api_base.as_ref().map(|_| api_base.clone());
811
812    let spec = ReleaseSpec {
813        manifest_path: cli.manifest_path.clone(),
814        registry: Registry {
815            name: cli
816                .registry
817                .clone()
818                .unwrap_or_else(|| "crates-io".to_string()),
819            api_base,
820            index_base,
821        },
822        selected_packages: if cli.packages.is_empty() {
823            None
824        } else {
825            Some(cli.packages.clone())
826        },
827    };
828
829    let command_name = cli
830        .cmd
831        .as_ref()
832        .map(command_name_for_hint)
833        .unwrap_or("command");
834    let mut planned = plan::build_plan(&spec)
835        .with_context(|| plan_failure_hint(&spec.manifest_path, &cli.packages, command_name))?;
836
837    // Load configuration file
838    let config =
839        if let Some(ref config_path) = cli.config {
840            // Use custom config file specified via --config
841            Some(ShipperConfig::load_from_file(config_path).with_context(|| {
842                format!("Failed to load config from: {}", config_path.display())
843            })?)
844        } else {
845            // Try to load .shipper.toml from workspace root
846            ShipperConfig::load_from_workspace(&planned.workspace_root)
847                .with_context(|| "Failed to load config from workspace")?
848        };
849
850    // Validate loaded configuration before using it for runtime options.
851    if let Some(ref cfg) = config {
852        let config_path = cli
853            .config
854            .clone()
855            .unwrap_or_else(|| planned.workspace_root.join(".shipper.toml"));
856        cfg.validate().with_context(|| {
857            format!(
858                "Configuration validation failed for {}",
859                config_path.display()
860            )
861        })?;
862    }
863
864    // Apply registry from config if CLI didn't set it
865    if let Some(ref cfg) = config
866        && let Some(ref reg_config) = cfg.registry
867    {
868        if cli.registry.is_none() {
869            planned.plan.registry.name = reg_config.name.clone();
870        }
871        if cli.api_base.is_none() {
872            planned.plan.registry.api_base = reg_config.api_base.clone();
873            planned.plan.registry.index_base = reg_config.index_base.clone();
874        }
875    }
876
877    // Build CLI overrides
878    let cli_overrides = CliOverrides {
879        policy: cli.policy.as_deref().map(parse_policy).transpose()?,
880        verify_mode: cli
881            .verify_mode
882            .as_deref()
883            .map(parse_verify_mode)
884            .transpose()?,
885        max_attempts: cli.max_attempts,
886        base_delay: cli.base_delay.as_deref().map(parse_duration).transpose()?,
887        max_delay: cli.max_delay.as_deref().map(parse_duration).transpose()?,
888        retry_strategy: cli
889            .retry_strategy
890            .as_deref()
891            .map(parse_retry_strategy)
892            .transpose()?,
893        retry_jitter: cli.retry_jitter,
894        verify_timeout: cli
895            .verify_timeout
896            .as_deref()
897            .map(parse_duration)
898            .transpose()?,
899        verify_poll_interval: cli.verify_poll.as_deref().map(parse_duration).transpose()?,
900        output_lines: cli.output_lines,
901        lock_timeout: cli
902            .lock_timeout
903            .as_deref()
904            .map(parse_duration)
905            .transpose()?,
906        state_dir: cli.state_dir.clone(),
907        readiness_method: cli
908            .readiness_method
909            .as_deref()
910            .map(parse_readiness_method)
911            .transpose()?,
912        readiness_timeout: cli
913            .readiness_timeout
914            .as_deref()
915            .map(parse_duration)
916            .transpose()?,
917        readiness_poll: cli
918            .readiness_poll
919            .as_deref()
920            .map(parse_duration)
921            .transpose()?,
922        allow_dirty: cli.allow_dirty,
923        skip_ownership_check: cli.skip_ownership_check,
924        strict_ownership: cli.strict_ownership,
925        no_verify: cli.no_verify,
926        no_readiness: cli.no_readiness,
927        force: cli.force,
928        force_resume: cli.force_resume,
929        parallel_enabled: cli.parallel || cli.max_concurrent.is_some(),
930        max_concurrent: cli.max_concurrent,
931        per_package_timeout: cli
932            .per_package_timeout
933            .as_deref()
934            .map(parse_duration)
935            .transpose()?,
936        webhook_url: cli.webhook_url.clone(),
937        webhook_secret: cli.webhook_secret.clone(),
938        encrypt: cli.encrypt,
939        encrypt_passphrase: cli.encrypt_passphrase.clone(),
940        registries: cli.registries.as_ref().map(|s| {
941            s.split(',')
942                .map(|s| s.trim().to_string())
943                .filter(|s| !s.is_empty())
944                .collect()
945        }),
946        all_registries: cli.all_registries,
947        resume_from: cli.resume_from.clone(),
948        rehearsal_registry: cli.rehearsal_registry.clone(),
949        skip_rehearsal: cli.skip_rehearsal,
950        rehearsal_smoke_install: cli.rehearsal_smoke_install.clone(),
951    };
952
953    // Merge CLI overrides with config (or defaults if no config)
954    let config_for_merge = config.clone().unwrap_or_default();
955    let opts: RuntimeOptions = config_for_merge.build_runtime_options(cli_overrides);
956
957    let mut reporter = CliReporter::new(cli.quiet);
958
959    match cli.cmd.expect("subcommand checked above") {
960        Commands::Plan => {
961            print_plan(&planned, cli.verbose, &cli.format);
962        }
963        Commands::Preflight { preflight_only } => {
964            let rep = engine::run_preflight_in_place_with_options(
965                &mut planned,
966                &opts,
967                &mut reporter,
968                engine::PreflightRunOptions {
969                    fresh_audit: preflight_only,
970                },
971            )
972            .with_context(|| preflight_failure_hint(&opts.state_dir))?;
973            print_preflight(&rep, &cli.format);
974        }
975        Commands::Publish => {
976            let target_registries = if opts.registries.is_empty() {
977                vec![planned.plan.registry.clone()]
978            } else {
979                opts.registries.clone()
980            };
981
982            for reg in target_registries {
983                if opts.registries.len() > 1 {
984                    if cli.format == "json" {
985                        eprintln!();
986                        eprintln!("Publishing to registry: {} ({})", reg.name, reg.api_base);
987                    } else {
988                        println!(
989                            "\n🚀 Publishing to registry: {} ({})",
990                            reg.name, reg.api_base
991                        );
992                    }
993                }
994
995                let mut current_planned = planned.clone();
996                current_planned.plan.registry = reg.clone();
997
998                let mut current_opts = opts.clone();
999                // Segregate state dir by registry name if multiple registries
1000                if opts.registries.len() > 1 {
1001                    current_opts.state_dir = opts.state_dir.join(&reg.name);
1002                }
1003
1004                let total_packages = current_planned.plan.packages.len();
1005                let mut progress = ProgressReporter::new(total_packages, cli.quiet);
1006                let package_positions: BTreeMap<String, usize> = current_planned
1007                    .plan
1008                    .packages
1009                    .iter()
1010                    .enumerate()
1011                    .map(|(idx, pkg)| (format!("{}@{}", pkg.name, pkg.version), idx + 1))
1012                    .collect();
1013
1014                // Show initial progress if we have packages
1015                if total_packages > 0 {
1016                    let first_pkg = &current_planned.plan.packages[0];
1017                    progress.set_package(1, &first_pkg.name, &first_pkg.version);
1018                }
1019
1020                // Install the progress handle on the reporter so the engine's
1021                // retry-backoff narration (#103) can drive a live TTY
1022                // countdown via ProgressReporter::retry_countdown.
1023                reporter.install_progress(progress, package_positions);
1024
1025                let receipt = engine::run_publish(&current_planned, &current_opts, &mut reporter)
1026                    .with_context(|| publish_failure_hint(&current_opts.state_dir))?;
1027
1028                if let Some(progress) = reporter.take_progress() {
1029                    progress.finish();
1030                }
1031
1032                print_publish_output(
1033                    &receipt,
1034                    &current_planned.workspace_root,
1035                    &current_opts.state_dir,
1036                    &cli.format,
1037                )?;
1038            }
1039        }
1040        Commands::Resume => {
1041            let target_registries = if opts.registries.is_empty() {
1042                vec![planned.plan.registry.clone()]
1043            } else {
1044                opts.registries.clone()
1045            };
1046
1047            for reg in target_registries {
1048                if opts.registries.len() > 1 {
1049                    if cli.format == "json" {
1050                        eprintln!();
1051                        eprintln!("Resuming for registry: {} ({})", reg.name, reg.api_base);
1052                    } else {
1053                        println!(
1054                            "\n🔄 Resuming for registry: {} ({})",
1055                            reg.name, reg.api_base
1056                        );
1057                    }
1058                }
1059
1060                let mut current_planned = planned.clone();
1061                current_planned.plan.registry = reg.clone();
1062
1063                let mut current_opts = opts.clone();
1064                if opts.registries.len() > 1 {
1065                    current_opts.state_dir = opts.state_dir.join(&reg.name);
1066                }
1067
1068                let total_packages = current_planned.plan.packages.len();
1069                let mut progress = ProgressReporter::new(total_packages, cli.quiet);
1070                let package_positions: BTreeMap<String, usize> = current_planned
1071                    .plan
1072                    .packages
1073                    .iter()
1074                    .enumerate()
1075                    .map(|(idx, pkg)| (format!("{}@{}", pkg.name, pkg.version), idx + 1))
1076                    .collect();
1077
1078                // Show initial progress if we have packages
1079                if total_packages > 0 {
1080                    let first_pkg = &current_planned.plan.packages[0];
1081                    progress.set_package(1, &first_pkg.name, &first_pkg.version);
1082                }
1083
1084                // Install the progress handle on the reporter so the engine's
1085                // retry-backoff narration (#103) can drive a live TTY
1086                // countdown via ProgressReporter::retry_countdown.
1087                reporter.install_progress(progress, package_positions);
1088
1089                let receipt = engine::run_resume(&current_planned, &current_opts, &mut reporter)
1090                    .with_context(|| resume_failure_hint(&current_opts.state_dir))?;
1091
1092                if let Some(progress) = reporter.take_progress() {
1093                    progress.finish();
1094                }
1095
1096                print_resume_output(
1097                    &receipt,
1098                    &current_planned.workspace_root,
1099                    &current_opts.state_dir,
1100                    &cli.format,
1101                )?;
1102            }
1103        }
1104        Commands::Rehearse => {
1105            let outcome = engine::run_rehearsal(&planned, &opts, &mut reporter)?;
1106
1107            // Stdout is the operator-facing receipt: mirrors the live
1108            // publish path, so a human scanning the terminal sees one
1109            // consistent "did it work?" line regardless of which command
1110            // they ran. Full per-package detail is in events.jsonl.
1111            if outcome.passed {
1112                println!(
1113                    "rehearsal OK: {} packages against '{}'",
1114                    outcome.packages_published, outcome.registry_name
1115                );
1116            } else {
1117                println!(
1118                    "rehearsal FAILED after {}/{} packages against '{}': {}",
1119                    outcome.packages_published,
1120                    outcome.packages_attempted,
1121                    outcome.registry_name,
1122                    outcome.summary
1123                );
1124                // Exit non-zero so CI lanes that wrap `shipper rehearse`
1125                // fail the job on a failed rehearsal without needing extra
1126                // scripting.
1127                anyhow::bail!("rehearsal did not pass");
1128            }
1129        }
1130        Commands::Status { watch } => {
1131            let target_registries = if opts.registries.is_empty() {
1132                vec![planned.plan.registry.clone()]
1133            } else {
1134                opts.registries.clone()
1135            };
1136
1137            if watch {
1138                if target_registries.len() > 1 {
1139                    bail!(
1140                        "status --watch supports one registry at a time; pass --registry once or inspect the registry-specific state directory directly"
1141                    );
1142                }
1143                run_status_watch(&planned, &opts, &cli.format)?;
1144                return Ok(());
1145            }
1146
1147            let mut registry_reports = Vec::new();
1148            for reg in target_registries {
1149                let mut current_planned = planned.clone();
1150                current_planned.plan.registry = reg;
1151                registry_reports.push(build_status_registry_report(
1152                    &current_planned,
1153                    &mut reporter,
1154                )?);
1155            }
1156            let report = StatusReport {
1157                schema_version: "shipper.status.v1",
1158                plan_id: planned.plan.plan_id.clone(),
1159                workspace_root: planned.workspace_root.display().to_string(),
1160                registries: registry_reports,
1161            };
1162            write_status_report(&report, &cli.format)?;
1163        }
1164        Commands::Doctor => {
1165            let target_registries = if opts.registries.is_empty() {
1166                vec![planned.plan.registry.clone()]
1167            } else {
1168                opts.registries.clone()
1169            };
1170
1171            if cli.format == "json" {
1172                let mut reports = Vec::new();
1173                for reg in target_registries {
1174                    let mut current_planned = planned.clone();
1175                    current_planned.plan.registry = reg;
1176                    reports.push(doctor::collect_report(&current_planned, &opts)?);
1177                }
1178                doctor::print_json(reports)?;
1179            } else {
1180                for reg in target_registries {
1181                    if opts.registries.len() > 1 {
1182                        println!(
1183                            "\n🩺 Diagnostics for registry: {} ({})",
1184                            reg.name,
1185                            doctor::redact_diagnostic_value(&reg.api_base)
1186                        );
1187                    }
1188                    let mut current_planned = planned.clone();
1189                    current_planned.plan.registry = reg;
1190                    doctor::run(&current_planned, &opts, &mut reporter)?;
1191                }
1192            }
1193        }
1194        Commands::InspectEvents { follow } => {
1195            run_inspect_events(&planned, &opts, &cli.format, follow)?;
1196        }
1197        Commands::InspectReceipt => {
1198            run_inspect_receipt(&planned, &opts, &cli.format)?;
1199        }
1200        Commands::Ci(ci_cmd) => {
1201            run_ci(ci_cmd, &opts.state_dir, &planned.workspace_root)?;
1202        }
1203        Commands::Yank {
1204            crate_name,
1205            version,
1206            reason,
1207            mark_compromised,
1208            plan,
1209        } => {
1210            use shipper_core::cargo;
1211            use shipper_core::engine::plan_yank;
1212            use shipper_core::state::events::{EventLog, events_path};
1213            use shipper_core::state::execution_state::{load_receipt, receipt_path, write_receipt};
1214            use shipper_core::types::{EventType, PublishEvent};
1215
1216            // #98 PR 5 — plan execution mode. Dispatched entirely
1217            // separately from the single-yank path below; the two share
1218            // the same cargo_yank primitive but different orchestration.
1219            if let Some(plan_path) = plan {
1220                let yank_plan = plan_yank::load_plan_from_path(&plan_path)?;
1221                reporter.info(&format!(
1222                    "executing yank plan: {} entries against '{}' (plan_id {})",
1223                    yank_plan.entries.len(),
1224                    yank_plan.registry,
1225                    yank_plan.plan_id
1226                ));
1227
1228                let workspace_root = std::env::current_dir()
1229                    .context("failed to resolve current dir for plan execution")?;
1230                let registry_name = opts
1231                    .registries
1232                    .first()
1233                    .map(|r| r.name.clone())
1234                    .unwrap_or_else(|| yank_plan.registry.clone());
1235
1236                let mut log = EventLog::new();
1237                let events_file = events_path(&opts.state_dir);
1238
1239                let mut succeeded = 0usize;
1240                let mut failed: Option<(String, i32)> = None;
1241
1242                for (i, entry) in yank_plan.entries.iter().enumerate() {
1243                    let entry_reason = entry
1244                        .reason
1245                        .clone()
1246                        .unwrap_or_else(|| "plan execution".to_string());
1247                    reporter.warn(&format!(
1248                        "[{}/{}] yanking {}@{} — reason: {}",
1249                        i + 1,
1250                        yank_plan.entries.len(),
1251                        entry.name,
1252                        entry.version,
1253                        entry_reason
1254                    ));
1255
1256                    let out = cargo::cargo_yank(
1257                        &workspace_root,
1258                        entry.name.as_str(),
1259                        entry.version.as_str(),
1260                        registry_name.as_str(),
1261                        opts.output_lines,
1262                        None,
1263                    )?;
1264
1265                    log.record(PublishEvent {
1266                        timestamp: chrono::Utc::now(),
1267                        event_type: EventType::PackageYanked {
1268                            crate_name: entry.name.clone(),
1269                            version: entry.version.clone(),
1270                            reason: entry_reason.clone(),
1271                            exit_code: out.exit_code,
1272                        },
1273                        package: format!("{}@{}", entry.name, entry.version),
1274                    });
1275                    if let Err(err) = log.write_to_file(&events_file) {
1276                        reporter.warn(&format!(
1277                            "failed to append PackageYanked event to {}: {err:#}",
1278                            events_file.display()
1279                        ));
1280                    }
1281                    log.clear();
1282
1283                    if out.exit_code == 0 {
1284                        succeeded += 1;
1285                        reporter.info(&format!(
1286                            "[{}/{}] yanked {}@{}",
1287                            i + 1,
1288                            yank_plan.entries.len(),
1289                            entry.name,
1290                            entry.version
1291                        ));
1292                    } else {
1293                        reporter.error(&format!(
1294                            "[{}/{}] cargo yank exited {} for {}@{}. stderr tail:\n{}",
1295                            i + 1,
1296                            yank_plan.entries.len(),
1297                            out.exit_code,
1298                            entry.name,
1299                            entry.version,
1300                            out.stderr_tail
1301                        ));
1302                        failed = Some((format!("{}@{}", entry.name, entry.version), out.exit_code));
1303                        // Halt on first failure. Plan is reverse-topo so
1304                        // every entry below this one is a dependent of
1305                        // something we just failed to yank — continuing
1306                        // would only produce more damage.
1307                        break;
1308                    }
1309                }
1310
1311                if let Some((pkg, code)) = failed {
1312                    reporter.error(&format!(
1313                        "yank plan halted: {succeeded}/{} succeeded; failed at {pkg} (cargo exit {code})",
1314                        yank_plan.entries.len()
1315                    ));
1316                    anyhow::bail!(
1317                        "yank plan failed at {pkg}; {succeeded}/{} entries succeeded before halt",
1318                        yank_plan.entries.len()
1319                    );
1320                } else {
1321                    reporter.info(&format!(
1322                        "yank plan complete: {succeeded}/{} entries yanked successfully",
1323                        yank_plan.entries.len()
1324                    ));
1325                    return Ok(());
1326                }
1327            }
1328
1329            // Single-yank mode (the original shape). All three fields
1330            // are required when `--plan` is absent; clap's
1331            // `conflicts_with` already rejected the mixed combinations.
1332            let crate_name = crate_name.ok_or_else(|| {
1333                anyhow::anyhow!("--crate is required when --plan is not supplied")
1334            })?;
1335            let version = version.ok_or_else(|| {
1336                anyhow::anyhow!("--version is required when --plan is not supplied")
1337            })?;
1338            let reason = reason.ok_or_else(|| {
1339                anyhow::anyhow!("--reason is required when --plan is not supplied")
1340            })?;
1341
1342            reporter.warn(&format!(
1343                "yanking {crate_name}@{version} from registry \
1344                 (containment, not undo) — reason: {reason}"
1345            ));
1346
1347            let workspace_root =
1348                std::env::current_dir().context("failed to resolve current dir for cargo yank")?;
1349            let registry_name = opts
1350                .registries
1351                .first()
1352                .map(|r| r.name.clone())
1353                .unwrap_or_else(|| "crates-io".to_string());
1354
1355            let out = cargo::cargo_yank(
1356                &workspace_root,
1357                crate_name.as_str(),
1358                version.as_str(),
1359                registry_name.as_str(),
1360                opts.output_lines,
1361                None,
1362            )?;
1363
1364            let mut log = EventLog::new();
1365            log.record(PublishEvent {
1366                timestamp: chrono::Utc::now(),
1367                event_type: EventType::PackageYanked {
1368                    crate_name: crate_name.clone(),
1369                    version: version.clone(),
1370                    reason: reason.clone(),
1371                    exit_code: out.exit_code,
1372                },
1373                package: format!("{crate_name}@{version}"),
1374            });
1375            let events_file = events_path(&opts.state_dir);
1376            if let Err(err) = log.write_to_file(&events_file) {
1377                reporter.warn(&format!(
1378                    "failed to append PackageYanked event to {}: {err:#}",
1379                    events_file.display()
1380                ));
1381            }
1382
1383            if out.exit_code == 0 {
1384                if mark_compromised {
1385                    // #98 PR 3: mirror the yank into the receipt so
1386                    // downstream commands (plan-yank --compromised-only,
1387                    // fix-forward) can find the marker without scanning
1388                    // events.jsonl. The receipt is a *projection*, so
1389                    // mutating one field on one matching package is a
1390                    // legitimate amendment.
1391                    let rpath = receipt_path(&opts.state_dir);
1392                    match load_receipt(&opts.state_dir) {
1393                        Ok(Some(mut receipt)) => {
1394                            let matched = receipt
1395                                .packages
1396                                .iter_mut()
1397                                .find(|p| p.name == crate_name && p.version == version);
1398                            if let Some(pkg) = matched {
1399                                pkg.compromised_at = Some(chrono::Utc::now());
1400                                pkg.compromised_by = Some(reason.clone());
1401                                if let Err(err) = write_receipt(&opts.state_dir, &receipt) {
1402                                    reporter.warn(&format!(
1403                                        "yanked successfully but failed to mark receipt at \
1404                                         {}: {err:#}",
1405                                        rpath.display()
1406                                    ));
1407                                } else {
1408                                    reporter.info(&format!(
1409                                        "marked {crate_name}@{version} compromised in {}",
1410                                        rpath.display()
1411                                    ));
1412                                }
1413                            } else {
1414                                reporter.warn(&format!(
1415                                    "--mark-compromised: no matching package entry for \
1416                                     {crate_name}@{version} in {}; yank succeeded but the \
1417                                     receipt was not amended.",
1418                                    rpath.display()
1419                                ));
1420                            }
1421                        }
1422                        Ok(None) => {
1423                            reporter.warn(&format!(
1424                                "--mark-compromised: no receipt at {}; yank succeeded but \
1425                                 nothing to amend. Future plan-yank / fix-forward runs won't \
1426                                 see this version as compromised unless the receipt is \
1427                                 reconstructed.",
1428                                rpath.display()
1429                            ));
1430                        }
1431                        Err(err) => {
1432                            reporter.warn(&format!(
1433                                "--mark-compromised: failed to load receipt at {}: {err:#}. \
1434                                 Yank succeeded; receipt not amended.",
1435                                rpath.display()
1436                            ));
1437                        }
1438                    }
1439                }
1440
1441                reporter.info(&format!(
1442                    "yanked {crate_name}@{version} successfully. \
1443                     existing lockfile pins are NOT invalidated; \
1444                     downstream consumers should `cargo update -p {crate_name}` \
1445                     to pick up the next available version."
1446                ));
1447            } else {
1448                reporter.error(&format!(
1449                    "cargo yank exited {} for {crate_name}@{version}. \
1450                     stderr tail:\n{}",
1451                    out.exit_code, out.stderr_tail
1452                ));
1453                anyhow::bail!(
1454                    "yank failed for {crate_name}@{version} (cargo exit {})",
1455                    out.exit_code
1456                );
1457            }
1458        }
1459        Commands::PlanYank {
1460            from_receipt,
1461            compromised_only,
1462            starting_crate,
1463            reason,
1464        } => {
1465            use shipper_core::engine::plan_yank::{self, PlanYankFilter};
1466
1467            let receipt_path = from_receipt.unwrap_or_else(|| {
1468                opts.state_dir
1469                    .join(shipper_core::state::execution_state::RECEIPT_FILE)
1470            });
1471
1472            let receipt = plan_yank::load_receipt_from_path(&receipt_path).with_context(|| {
1473                "plan-yank needs a readable receipt; default path is \
1474                 <state_dir>/receipt.json. Pass --from-receipt <path> to \
1475                 override."
1476                    .to_string()
1477            })?;
1478
1479            // Three mutually-informative modes:
1480            //   --starting-crate <N>   → graph mode (walk dependents)
1481            //   --compromised-only     → receipt-filter mode (marker)
1482            //   (default)              → receipt-filter mode (all Published)
1483            // clap's `conflicts_with` already rejects combinations at parse time.
1484            let plan = if let Some(ref starting) = starting_crate {
1485                // Graph mode uses the *current workspace's* dependency graph,
1486                // read from the planned workspace we already built upstream.
1487                plan_yank::build_plan_from_starting_crate(
1488                    &receipt,
1489                    &planned.plan.dependencies,
1490                    starting,
1491                    reason.clone(),
1492                )?
1493            } else {
1494                let filter = if compromised_only {
1495                    PlanYankFilter::CompromisedOnly
1496                } else {
1497                    PlanYankFilter::AllPublished
1498                };
1499                plan_yank::build_plan(&receipt, filter)
1500            };
1501
1502            match cli.format.as_str() {
1503                "json" => {
1504                    let report = PlanYankJsonReport {
1505                        schema_version: "shipper.plan_yank.v1",
1506                        command: "plan-yank",
1507                        plan: &plan,
1508                    };
1509                    let out = serde_json::to_string_pretty(&report)
1510                        .context("failed to serialize yank plan as JSON")?;
1511                    println!("{out}");
1512                }
1513                _ => {
1514                    println!("{}", plan_yank::render_text(&plan));
1515                }
1516            }
1517        }
1518        Commands::FixForward { from_receipt } => {
1519            use shipper_core::engine::fix_forward::{self, SuccessorStrategy};
1520
1521            let receipt_path = from_receipt.unwrap_or_else(|| {
1522                opts.state_dir
1523                    .join(shipper_core::state::execution_state::RECEIPT_FILE)
1524            });
1525
1526            let plan =
1527                fix_forward::plan_from_path(&receipt_path, SuccessorStrategy::PlaceholderNext)
1528                    .with_context(|| {
1529                        "fix-forward needs a readable receipt; default path is \
1530                         <state_dir>/receipt.json. Pass --from-receipt <path> to \
1531                         override."
1532                            .to_string()
1533                    })?;
1534
1535            match cli.format.as_str() {
1536                "json" => {
1537                    let report = FixForwardJsonReport {
1538                        schema_version: "shipper.fix_forward.v1",
1539                        command: "fix-forward",
1540                        plan: &plan,
1541                    };
1542                    let out = serde_json::to_string_pretty(&report)
1543                        .context("failed to serialize fix-forward plan as JSON")?;
1544                    println!("{out}");
1545                }
1546                _ => {
1547                    println!("{}", fix_forward::render_text(&plan));
1548                }
1549            }
1550        }
1551        Commands::Remediate {
1552            from_receipt,
1553            crate_name,
1554            target_version,
1555            reason,
1556            dry_run,
1557            execute_plan,
1558        } => {
1559            use shipper_core::cargo;
1560            use shipper_core::engine::{plan_yank, remediation};
1561            use shipper_core::runtime::execution::resolve_state_dir;
1562            use shipper_core::state::events::{EventLog, events_path};
1563
1564            if let Some(plan_path) = execute_plan {
1565                let state_dir = resolve_state_dir(&planned.workspace_root, &opts.state_dir);
1566                let expected_plan_path =
1567                    shipper_core::state::execution_state::remediation_plan_path(&state_dir);
1568                let requested_plan_path = plan_path.canonicalize().with_context(|| {
1569                    format!(
1570                        "failed to resolve remediation plan path {}; execute reviewed plans from <state-dir>/remediation-plan.json",
1571                        plan_path.display()
1572                    )
1573                })?;
1574                let expected_plan_path = expected_plan_path.canonicalize().with_context(|| {
1575                    format!(
1576                        "failed to resolve expected remediation plan {}; run `shipper remediate --dry-run` first or pass --state-dir",
1577                        expected_plan_path.display()
1578                    )
1579                })?;
1580                if requested_plan_path != expected_plan_path {
1581                    anyhow::bail!(
1582                        "refusing to execute remediation plan outside the configured state dir; expected {}",
1583                        expected_plan_path.display()
1584                    );
1585                }
1586
1587                let plan = remediation::load_plan_from_path(&expected_plan_path)?;
1588                let events_file = events_path(&state_dir);
1589                let registry_name = opts
1590                    .registries
1591                    .first()
1592                    .map(|r| r.name.clone())
1593                    .unwrap_or_else(|| "crates-io".to_string());
1594                if plan.registry != registry_name {
1595                    anyhow::bail!(
1596                        "remediation plan registry '{}' does not match configured registry '{}'",
1597                        plan.registry,
1598                        registry_name
1599                    );
1600                }
1601
1602                reporter.warn(&format!(
1603                    "executing reviewed remediation plan: {} containment yanks for {}@{} (plan_id {})",
1604                    plan.yank_order.len(),
1605                    plan.target.crate_name,
1606                    plan.target.version,
1607                    plan.plan_id
1608                ));
1609                reporter.warn(
1610                    "remediate --execute-plan runs yanks only; fix-forward suggestions remain planning output",
1611                );
1612
1613                let mut succeeded = 0usize;
1614                for (idx, step) in plan.yank_order.iter().enumerate() {
1615                    let event_reason = remediation::REDACTED_OPERATOR_REASON.to_string();
1616                    reporter.warn(&format!(
1617                        "[{}/{}] yanking {}@{} from {}",
1618                        idx + 1,
1619                        plan.yank_order.len(),
1620                        step.name,
1621                        step.version,
1622                        registry_name
1623                    ));
1624
1625                    let out = cargo::cargo_yank(
1626                        &planned.workspace_root,
1627                        step.name.as_str(),
1628                        step.version.as_str(),
1629                        registry_name.as_str(),
1630                        opts.output_lines,
1631                        None,
1632                    )?;
1633
1634                    let mut log = EventLog::new();
1635                    log.record(PublishEvent {
1636                        timestamp: chrono::Utc::now(),
1637                        event_type: EventType::PackageYanked {
1638                            crate_name: step.name.clone(),
1639                            version: step.version.clone(),
1640                            reason: event_reason,
1641                            exit_code: out.exit_code,
1642                        },
1643                        package: format!("{}@{}", step.name, step.version),
1644                    });
1645                    if let Err(err) = log.write_to_file(&events_file) {
1646                        reporter.warn(&format!(
1647                            "failed to append PackageYanked event to {}: {err:#}",
1648                            events_file.display()
1649                        ));
1650                    }
1651
1652                    if out.exit_code == 0 {
1653                        succeeded += 1;
1654                        reporter.info(&format!(
1655                            "[{}/{}] yanked {}@{}",
1656                            idx + 1,
1657                            plan.yank_order.len(),
1658                            step.name,
1659                            step.version
1660                        ));
1661                    } else {
1662                        reporter.error(&format!(
1663                            "[{}/{}] cargo yank exited {} for {}@{}. stderr tail:\n{}",
1664                            idx + 1,
1665                            plan.yank_order.len(),
1666                            out.exit_code,
1667                            step.name,
1668                            step.version,
1669                            out.stderr_tail
1670                        ));
1671                        anyhow::bail!(
1672                            "remediation plan failed at {}@{}; {succeeded}/{} containment yanks succeeded before halt",
1673                            step.name,
1674                            step.version,
1675                            plan.yank_order.len()
1676                        );
1677                    }
1678                }
1679
1680                reporter.info(&format!(
1681                    "remediation containment complete: {succeeded}/{} yanks executed successfully",
1682                    plan.yank_order.len()
1683                ));
1684                return Ok(());
1685            }
1686
1687            if !dry_run {
1688                bail!("remediate currently supports planning only; rerun with --dry-run");
1689            }
1690            let crate_name = crate_name.ok_or_else(|| {
1691                anyhow::anyhow!("--crate is required when --execute-plan is not supplied")
1692            })?;
1693            let target_version = target_version.ok_or_else(|| {
1694                anyhow::anyhow!("--target-version is required when --execute-plan is not supplied")
1695            })?;
1696            let reason = reason.ok_or_else(|| {
1697                anyhow::anyhow!("--reason is required when --execute-plan is not supplied")
1698            })?;
1699
1700            let state_dir = resolve_state_dir(&planned.workspace_root, &opts.state_dir);
1701            let receipt_path = from_receipt
1702                .unwrap_or_else(|| shipper_core::state::execution_state::receipt_path(&state_dir));
1703            let receipt = plan_yank::load_receipt_from_path(&receipt_path).with_context(|| {
1704                "remediate needs a readable receipt; default path is \
1705                 <state_dir>/receipt.json. Pass --from-receipt <path> to \
1706                 override."
1707                    .to_string()
1708            })?;
1709
1710            let plan = remediation::build_dry_run_plan(
1711                &receipt,
1712                &planned.plan.dependencies,
1713                &receipt_path,
1714                &crate_name,
1715                &target_version,
1716                &reason,
1717            )?;
1718            let artifact_path = remediation::write_dry_run_artifact(&state_dir, &plan)?;
1719
1720            match cli.format.as_str() {
1721                "json" => {
1722                    let out = serde_json::to_string_pretty(&plan)
1723                        .context("failed to serialize remediation dry-run plan as JSON")?;
1724                    println!("{out}");
1725                }
1726                _ => {
1727                    println!("{}", remediation::render_text(&plan, &artifact_path));
1728                }
1729            }
1730        }
1731        Commands::Clean { keep_receipt } => {
1732            run_clean(
1733                &opts.state_dir,
1734                &planned.workspace_root,
1735                keep_receipt,
1736                opts.force,
1737            )?;
1738        }
1739        Commands::Config(_) => {
1740            // This should never be reached since we handle Config commands early
1741            unreachable!("Config commands should be handled before this match");
1742        }
1743        Commands::Completion { .. } => {
1744            // This should never be reached since we handle Completion commands early
1745            unreachable!("Completion commands should be handled before this match");
1746        }
1747    }
1748
1749    Ok(())
1750}
1751
1752/// Operator-facing hint attached to a failed `preflight` run.
1753///
1754/// Preflight errors are almost always "something about your environment
1755/// isn't ready yet" — missing token, dirty git, registry unreachable —
1756/// and the answer is almost always `shipper doctor`. Point operators
1757/// there so they don't have to guess.
1758fn preflight_failure_hint(state_dir: &Path) -> String {
1759    let hint = format!(
1760        "preflight failed — next steps:\n  \
1761         * run `shipper doctor` to diagnose auth / git / registry\n  \
1762         * inspect {}/events.jsonl for the authoritative event log\n  \
1763         * `shipper preflight --format json` for machine-readable detail",
1764        state_dir.display()
1765    );
1766    with_common_blockers(
1767        hint,
1768        &[
1769            "missing token/auth: run `cargo login <token>` or configure Trusted Publishing",
1770            "dirty git: commit or stash changes, or pass `--allow-dirty` only for intentional rehearsal",
1771            "version already exists: run `shipper status`, then bump or skip the crate version",
1772            "ownership failure: confirm the token can publish with `cargo owner --list <crate>`",
1773            "registry unreachable: verify `--registry`, `--api-base`, and network access",
1774        ],
1775    )
1776}
1777
1778/// Operator-facing hint attached to a failed `publish` run.
1779///
1780/// Publish can fail mid-plan (network, ambiguous response, auth, version
1781/// collision). In every case the authoritative record is
1782/// `events.jsonl`; resuming (once the root cause is fixed) is how you
1783/// continue without re-uploading successfully-published crates.
1784fn publish_failure_hint(state_dir: &Path) -> String {
1785    let hint = format!(
1786        "publish failed — next steps:\n  \
1787         * inspect {dir}/events.jsonl (authoritative) and {dir}/state.json (projection)\n  \
1788         * run `shipper status` to compare local versions to the registry\n  \
1789         * run `shipper resume` after fixing the root cause to continue from the failed crate\n  \
1790         * run `shipper doctor` if auth / network is suspect",
1791        dir = state_dir.display()
1792    );
1793    with_common_blockers(
1794        hint,
1795        &[
1796            "ambiguous publish: inspect reconciliation evidence; do not blind-retry outside Shipper",
1797            "rate limit or Retry-After: wait for Shipper's scheduled retry instead of restarting",
1798            "version already exists: run `shipper status` before deciding to bump or resume",
1799            "stale lock: verify no release is active before using `--force` or `shipper clean`",
1800            "auth/network failure: run `shipper doctor` before resuming",
1801        ],
1802    )
1803}
1804
1805/// Operator-facing hint attached to a failed `resume` run.
1806///
1807/// The most common resume failure is a plan-ID mismatch: the workspace
1808/// changed since the interrupted run, so the computed plan no longer
1809/// matches the one recorded in `state.json`. Point operators at the
1810/// two real paths out — delete state, or `--force-resume`.
1811fn resume_failure_hint(state_dir: &Path) -> String {
1812    let hint = format!(
1813        "resume failed — next steps:\n  \
1814         * if plan-ID mismatch: either `shipper clean` and start a fresh plan, \
1815         or pass `--force-resume` if you understand the divergence\n  \
1816         * inspect {dir}/events.jsonl for the authoritative event log\n  \
1817         * inspect {dir}/state.json to see what was already published\n  \
1818         * run `shipper status` to compare local versions to the registry",
1819        dir = state_dir.display()
1820    );
1821    with_common_blockers(
1822        hint,
1823        &[
1824            "state mismatch: compare the current plan with the saved `plan_id` before forcing",
1825            "corrupt state: preserve `events.jsonl`, then rebuild or clean state intentionally",
1826            "stale lock: verify no other release process owns the lock before forcing",
1827            "ambiguous state: inspect `reconciliation.json` and let resume reconcile registry truth",
1828        ],
1829    )
1830}
1831
1832fn plan_failure_hint(manifest_path: &Path, packages: &[String], command_name: &str) -> String {
1833    let mut hint = format!(
1834        "failed to load release plan for `{command_name}` - next steps:\n  \
1835         * verify `--manifest-path` points at the workspace Cargo.toml: {}\n  \
1836         * run `cargo metadata --manifest-path \"{}\"` to inspect the underlying Cargo error",
1837        manifest_path.display(),
1838        manifest_path.display()
1839    );
1840
1841    if packages.is_empty() {
1842        hint.push_str("\n  * run `shipper plan` first to inspect publishable and skipped crates");
1843    } else {
1844        hint.push_str(
1845            "\n  * run `shipper plan` without `--package` to list publishable crates\n  \
1846             * verify each selected `--package` is publishable and not marked `publish = false`",
1847        );
1848    }
1849
1850    with_common_blockers(
1851        hint,
1852        &[
1853            "missing manifest: pass `--manifest-path <workspace>/Cargo.toml`",
1854            "selected package not publishable: check `publish = false` and package spelling",
1855            "Cargo metadata failure: run the printed `cargo metadata` command directly",
1856        ],
1857    )
1858}
1859
1860fn with_common_blockers(mut hint: String, blockers: &[&str]) -> String {
1861    if blockers.is_empty() {
1862        return hint;
1863    }
1864
1865    hint.push_str("\n  Common blockers to check:");
1866    for blocker in blockers {
1867        hint.push_str("\n  * ");
1868        hint.push_str(blocker);
1869    }
1870    hint
1871}
1872
1873fn command_name_for_hint(command: &Commands) -> &'static str {
1874    match command {
1875        Commands::Plan => "plan",
1876        Commands::Preflight { .. } => "preflight",
1877        Commands::Publish => "publish",
1878        Commands::Resume => "resume",
1879        Commands::Rehearse => "rehearse",
1880        Commands::Status { .. } => "status",
1881        Commands::Doctor => "doctor",
1882        Commands::InspectEvents { .. } => "inspect-events",
1883        Commands::InspectReceipt => "inspect-receipt",
1884        Commands::Ci(_) => "ci",
1885        Commands::Clean { .. } => "clean",
1886        Commands::Yank { .. } => "yank",
1887        Commands::PlanYank { .. } => "plan-yank",
1888        Commands::FixForward { .. } => "fix-forward",
1889        Commands::Remediate { .. } => "remediate",
1890        Commands::Config(_) => "config",
1891        Commands::Completion { .. } => "completion",
1892    }
1893}
1894
1895fn parse_duration(s: &str) -> Result<Duration> {
1896    shipper_duration::parse_duration(s).with_context(|| format!("invalid duration: {s}"))
1897}
1898
1899fn parse_policy(s: &str) -> Result<shipper_core::config::PublishPolicy> {
1900    match s.to_lowercase().as_str() {
1901        "safe" => Ok(shipper_core::config::PublishPolicy::Safe),
1902        "balanced" => Ok(shipper_core::config::PublishPolicy::Balanced),
1903        "fast" => Ok(shipper_core::config::PublishPolicy::Fast),
1904        _ => bail!("invalid policy: {s} (expected: safe, balanced, fast)"),
1905    }
1906}
1907
1908fn parse_verify_mode(s: &str) -> Result<shipper_core::config::VerifyMode> {
1909    match s.to_lowercase().as_str() {
1910        "workspace" => Ok(shipper_core::config::VerifyMode::Workspace),
1911        "package" => Ok(shipper_core::config::VerifyMode::Package),
1912        "none" => Ok(shipper_core::config::VerifyMode::None),
1913        _ => bail!("invalid verify-mode: {s} (expected: workspace, package, none)"),
1914    }
1915}
1916
1917fn parse_readiness_method(s: &str) -> Result<shipper_core::config::ReadinessMethod> {
1918    match s.to_lowercase().as_str() {
1919        "api" => Ok(shipper_core::config::ReadinessMethod::Api),
1920        "index" => Ok(shipper_core::config::ReadinessMethod::Index),
1921        "both" => Ok(shipper_core::config::ReadinessMethod::Both),
1922        _ => bail!("invalid readiness-method: {s} (expected: api, index, both)"),
1923    }
1924}
1925
1926fn parse_retry_strategy(s: &str) -> Result<shipper_core::retry::RetryStrategyType> {
1927    match s.to_lowercase().as_str() {
1928        "immediate" => Ok(shipper_core::retry::RetryStrategyType::Immediate),
1929        "exponential" => Ok(shipper_core::retry::RetryStrategyType::Exponential),
1930        "linear" => Ok(shipper_core::retry::RetryStrategyType::Linear),
1931        "constant" => Ok(shipper_core::retry::RetryStrategyType::Constant),
1932        _ => bail!(
1933            "invalid retry-strategy: {s} (expected: immediate, exponential, linear, constant)"
1934        ),
1935    }
1936}
1937
1938fn print_version(verbose: bool) {
1939    println!("shipper {}", env!("CARGO_PKG_VERSION"));
1940    if verbose {
1941        println!("{RICH_VERSION_DETAILS}");
1942    }
1943}
1944
1945#[derive(Debug, Serialize)]
1946struct PlanReport {
1947    schema_version: &'static str,
1948    plan_id: String,
1949    registry: PlanRegistryReport,
1950    workspace_root: String,
1951    publishable_count: usize,
1952    skipped_count: usize,
1953    internal_dependency_edges: usize,
1954    publish_levels: usize,
1955    artifacts: Vec<PlanArtifactReport>,
1956    packages: Vec<PlanPackageReport>,
1957    skipped: Vec<PlanSkippedPackageReport>,
1958}
1959
1960#[derive(Debug, Serialize)]
1961struct PlanRegistryReport {
1962    name: String,
1963    api_base: String,
1964    #[serde(skip_serializing_if = "Option::is_none")]
1965    index_base: Option<String>,
1966}
1967
1968#[derive(Debug, Serialize)]
1969struct PlanPackageReport {
1970    order: usize,
1971    name: String,
1972    version: String,
1973    manifest_path: String,
1974    level: Option<usize>,
1975    dependencies: Vec<String>,
1976    order_reason: String,
1977}
1978
1979#[derive(Debug, Serialize)]
1980struct PlanSkippedPackageReport {
1981    name: String,
1982    version: String,
1983    reason: String,
1984}
1985
1986#[derive(Debug, Serialize)]
1987struct PlanArtifactReport {
1988    kind: &'static str,
1989    path: String,
1990    description: &'static str,
1991}
1992
1993#[derive(Debug, Serialize)]
1994struct PreflightJsonReport<'a> {
1995    schema_version: &'static str,
1996    #[serde(flatten)]
1997    report: &'a PreflightReport,
1998    proofs: Vec<PreflightEvidenceItem>,
1999    gaps: Vec<PreflightEvidenceItem>,
2000    failed_checks: Vec<PreflightEvidenceItem>,
2001    live_release_evidence: Vec<PreflightEvidenceItem>,
2002    #[serde(skip_serializing_if = "Option::is_none")]
2003    registry_profile: Option<PreflightRegistryProfileReport>,
2004    artifacts: Vec<PreflightArtifactReport>,
2005}
2006
2007#[derive(Debug, Serialize)]
2008struct PreflightEvidenceItem {
2009    id: &'static str,
2010    status: &'static str,
2011    summary: String,
2012    packages: Vec<String>,
2013}
2014
2015#[derive(Debug, Serialize)]
2016struct PreflightRegistryProfileReport {
2017    name: String,
2018    first_publish_count: usize,
2019    update_count: usize,
2020    minimum_registry_pacing: String,
2021    notes: Vec<String>,
2022}
2023
2024#[derive(Debug, Serialize)]
2025struct PreflightArtifactReport {
2026    kind: &'static str,
2027    path: Option<String>,
2028    description: &'static str,
2029}
2030
2031fn print_plan(ws: &plan::PlannedWorkspace, verbose: bool, format: &str) {
2032    if format == "json" {
2033        let report = build_plan_report(ws);
2034        let json = serde_json::to_string_pretty(&report).expect("serialize plan report");
2035        println!("{}", json);
2036        return;
2037    }
2038
2039    println!("plan_id: {}", ws.plan.plan_id);
2040    println!(
2041        "registry: {} ({})",
2042        ws.plan.registry.name, ws.plan.registry.api_base
2043    );
2044    println!("workspace_root: {}", ws.workspace_root.display());
2045    println!();
2046
2047    let total_packages = ws.plan.packages.len();
2048    println!("Total packages to publish: {}", total_packages);
2049    println!("Plan summary:");
2050    println!("  Publishable packages: {}", total_packages);
2051    println!("  Skipped packages: {}", ws.skipped.len());
2052    println!(
2053        "  Internal dependency edges: {}",
2054        internal_dependency_edges(&ws.plan)
2055    );
2056    println!("  Publish levels: {}", ws.plan.group_by_levels().len());
2057    println!("  Plan artifact: .shipper/plan.txt (`shipper plan --format json` capture)");
2058    println!();
2059
2060    if !ws.skipped.is_empty() {
2061        println!("Skipped packages:");
2062        for p in &ws.skipped {
2063            println!("  - {}@{} ({})", p.name, p.version, p.reason);
2064        }
2065        println!();
2066    }
2067
2068    if verbose {
2069        // Enhanced verbose output with dependency analysis
2070        print_detailed_plan(ws);
2071    } else {
2072        // Simple output
2073        for (idx, p) in ws.plan.packages.iter().enumerate() {
2074            println!(
2075                "{:>3}. {}@{} ({})",
2076                idx + 1,
2077                p.name,
2078                p.version,
2079                dependency_summary(&ws.plan, p)
2080            );
2081        }
2082    }
2083}
2084
2085fn build_plan_report(ws: &plan::PlannedWorkspace) -> PlanReport {
2086    let levels = ws.plan.group_by_levels();
2087    let packages = ws
2088        .plan
2089        .packages
2090        .iter()
2091        .enumerate()
2092        .map(|(idx, package)| {
2093            let dependencies = dependency_names(&ws.plan, package);
2094            let level = levels
2095                .iter()
2096                .find(|level| {
2097                    level
2098                        .packages
2099                        .iter()
2100                        .any(|level_pkg| level_pkg.name == package.name)
2101                })
2102                .map(|level| level.level);
2103
2104            PlanPackageReport {
2105                order: idx + 1,
2106                name: package.name.clone(),
2107                version: package.version.clone(),
2108                manifest_path: package.manifest_path.display().to_string(),
2109                level,
2110                dependencies,
2111                order_reason: dependency_summary(&ws.plan, package),
2112            }
2113        })
2114        .collect();
2115
2116    let skipped = ws
2117        .skipped
2118        .iter()
2119        .map(|package| PlanSkippedPackageReport {
2120            name: package.name.clone(),
2121            version: package.version.clone(),
2122            reason: package.reason.clone(),
2123        })
2124        .collect();
2125
2126    PlanReport {
2127        schema_version: "shipper.plan.v1",
2128        plan_id: ws.plan.plan_id.clone(),
2129        registry: PlanRegistryReport {
2130            name: ws.plan.registry.name.clone(),
2131            api_base: ws.plan.registry.api_base.clone(),
2132            index_base: ws.plan.registry.index_base.clone(),
2133        },
2134        workspace_root: ws.workspace_root.display().to_string(),
2135        publishable_count: ws.plan.packages.len(),
2136        skipped_count: ws.skipped.len(),
2137        internal_dependency_edges: internal_dependency_edges(&ws.plan),
2138        publish_levels: levels.len(),
2139        artifacts: vec![plan_artifact_report()],
2140        packages,
2141        skipped,
2142    }
2143}
2144
2145fn plan_artifact_report() -> PlanArtifactReport {
2146    PlanArtifactReport {
2147        kind: "plan_json_stdout",
2148        path: ".shipper/plan.txt".to_string(),
2149        description: "Recommended CI capture path for `shipper plan --format json`.",
2150    }
2151}
2152
2153fn internal_dependency_edges(plan: &ReleasePlan) -> usize {
2154    plan.dependencies.values().map(Vec::len).sum()
2155}
2156
2157fn dependency_summary(plan: &ReleasePlan, package: &PlannedPackage) -> String {
2158    let dependencies = dependency_names(plan, package);
2159    if dependencies.is_empty() {
2160        "no workspace dependencies".to_string()
2161    } else {
2162        format!("depends on: {}", dependencies.join(", "))
2163    }
2164}
2165
2166fn dependency_names(plan: &ReleasePlan, package: &PlannedPackage) -> Vec<String> {
2167    plan.dependencies
2168        .get(&package.name)
2169        .map(|dependencies| {
2170            dependencies
2171                .iter()
2172                .filter_map(|dependency| {
2173                    plan.packages
2174                        .iter()
2175                        .find(|candidate| candidate.name == *dependency)
2176                        .map(|candidate| format!("{}@{}", candidate.name, candidate.version))
2177                })
2178                .collect()
2179        })
2180        .unwrap_or_default()
2181}
2182
2183fn print_detailed_plan(ws: &plan::PlannedWorkspace) {
2184    // Get dependency levels for parallel publishing analysis
2185    let levels = ws.plan.group_by_levels();
2186    let total_levels = levels.len();
2187
2188    println!("=== Dependency Analysis ===");
2189    println!();
2190
2191    // Show dependency levels for parallel publishing
2192    println!("Publishing Levels (packages at same level can be published in parallel):");
2193    println!();
2194    for level in &levels {
2195        let level_pkgs: Vec<String> = level
2196            .packages
2197            .iter()
2198            .map(|p| format!("{}@{}", p.name, p.version))
2199            .collect();
2200        println!("  Level {}: {}", level.level, level_pkgs.join(", "));
2201    }
2202    println!();
2203
2204    // Show full dependency graph
2205    println!("Dependency Graph:");
2206    println!();
2207    for (idx, p) in ws.plan.packages.iter().enumerate() {
2208        println!(
2209            "  {:>3}. {}@{} ({})",
2210            idx + 1,
2211            p.name,
2212            p.version,
2213            dependency_summary(&ws.plan, p)
2214        );
2215    }
2216    println!();
2217
2218    // Show potential issues / preflight considerations
2219    println!("=== Preflight Considerations ===");
2220    println!();
2221
2222    // Analyze potential issues
2223    let mut issues: Vec<String> = Vec::new();
2224
2225    // Check for packages with many dependencies (may take longer)
2226    for p in &ws.plan.packages {
2227        #[allow(clippy::collapsible_if)]
2228        if let Some(deps) = ws.plan.dependencies.get(&p.name) {
2229            if deps.len() > 3 {
2230                issues.push(format!(
2231                    "  - {}@{} has {} dependencies (may require longer publish time)",
2232                    p.name,
2233                    p.version,
2234                    deps.len()
2235                ));
2236            }
2237        }
2238    }
2239
2240    // Check for packages that are depended upon by many others
2241    let mut dependents_count: std::collections::HashMap<&str, usize> =
2242        std::collections::HashMap::new();
2243    for deps in ws.plan.dependencies.values() {
2244        for dep in deps {
2245            *dependents_count.entry(dep.as_str()).or_insert(0) += 1;
2246        }
2247    }
2248    for (name, count) in &dependents_count {
2249        #[allow(clippy::collapsible_if)]
2250        if *count > 3 {
2251            if let Some(pkg) = ws.plan.packages.iter().find(|p| p.name == *name) {
2252                issues.push(format!(
2253                    "  - {}@{} is a core dependency for {} packages (critical path)",
2254                    pkg.name, pkg.version, count
2255                ));
2256            }
2257        }
2258    }
2259
2260    if issues.is_empty() {
2261        println!("  No obvious issues detected.");
2262        println!("  All packages have reasonable dependency structures.");
2263    } else {
2264        for issue in &issues {
2265            println!("{}", issue);
2266        }
2267    }
2268    println!();
2269
2270    // Estimate time analysis (rough estimates)
2271    println!("=== Estimated Publishing Analysis ===");
2272    println!();
2273
2274    // Calculate max parallel packages per level
2275    let max_parallel = levels.iter().map(|l| l.packages.len()).max().unwrap_or(0);
2276    println!(
2277        "  Parallel publishing: {}",
2278        if max_parallel > 1 {
2279            "enabled"
2280        } else {
2281            "sequential"
2282        }
2283    );
2284    println!("  Max concurrent packages: {}", max_parallel);
2285    println!("  Total publish levels: {}", total_levels);
2286
2287    // Rough time estimate (assuming ~30s per package + network overhead)
2288    let total_packages = ws.plan.packages.len();
2289    let estimated_sequential_secs = total_packages * 30;
2290    let estimated_parallel_secs = levels.iter().map(|_l| 30).sum::<usize>();
2291    println!(
2292        "  Estimated time (sequential): ~{}s ({:.1}min)",
2293        estimated_sequential_secs,
2294        estimated_sequential_secs as f64 / 60.0
2295    );
2296    println!(
2297        "  Estimated time (parallel): ~{}s ({:.1}min)",
2298        estimated_parallel_secs,
2299        estimated_parallel_secs as f64 / 60.0
2300    );
2301    println!();
2302
2303    // Show final publish order
2304    println!("=== Full Publish Order ===");
2305    println!();
2306    for (idx, p) in ws.plan.packages.iter().enumerate() {
2307        let level = levels
2308            .iter()
2309            .find(|l| l.packages.iter().any(|lp| lp.name == p.name));
2310        let level_str = level
2311            .map(|l| format!("[Level {}]", l.level))
2312            .unwrap_or_else(|| "[?]".to_string());
2313        println!("  {:>3}. {} {} @{}", idx + 1, level_str, p.name, p.version);
2314    }
2315}
2316
2317fn print_preflight(rep: &PreflightReport, format: &str) {
2318    match format {
2319        "json" => {
2320            let report = build_preflight_json_report(rep);
2321            let json = serde_json::to_string_pretty(&report).expect("serialize preflight report");
2322            println!("{}", json);
2323        }
2324        _ => {
2325            println!("Preflight Report");
2326            println!("===============");
2327            println!();
2328            println!("Plan ID: {}", rep.plan_id);
2329            println!("Timestamp: {}", rep.timestamp.format("%Y-%m-%dT%H:%M:%SZ"));
2330            println!();
2331            println!(
2332                "Token Detected: {}",
2333                if rep.token_detected { "✓" } else { "✗" }
2334            );
2335            println!();
2336
2337            // Display finishability with color-coded status
2338            let (finishability_color, finishability_text) = match rep.finishability {
2339                Finishability::Proven => ("\x1b[32m", "PROVEN"),
2340                Finishability::NotProven => ("\x1b[33m", "NOT PROVEN"),
2341                Finishability::Failed => ("\x1b[31m", "FAILED"),
2342            };
2343            println!(
2344                "Finishability: {}{}",
2345                finishability_color, finishability_text
2346            );
2347            println!();
2348
2349            // Display packages in table format
2350            println!("Packages:");
2351            println!(
2352                "┌─────────────────────┬─────────┬──────────┬──────────┬───────────────┬─────────────┬─────────────┐"
2353            );
2354            println!(
2355                "│ Package             │ Version │ Published│ New Crate │ Auth Type     │ Ownership   │ Dry-run     │"
2356            );
2357            println!(
2358                "├─────────────────────┼─────────┼──────────┼──────────┼───────────────┼─────────────┼─────────────┤"
2359            );
2360            for p in &rep.packages {
2361                let published = if p.already_published { "Yes" } else { "No" };
2362                let new_crate = if p.is_new_crate { "Yes" } else { "No" };
2363                let auth_type = match p.auth_type {
2364                    Some(shipper_core::types::AuthType::Token) => "Token",
2365                    Some(shipper_core::types::AuthType::TrustedPublishing) => "Trusted",
2366                    Some(shipper_core::types::AuthType::Unknown) => "Unknown",
2367                    None => "-",
2368                };
2369                let ownership = if p.ownership_verified { "✓" } else { "✗" };
2370                let dry_run = if p.dry_run_passed { "✓" } else { "✗" };
2371
2372                println!(
2373                    "│ {:<19} │ {:<7} │ {:<8} │ {:<8} │ {:<13} │ {:<11} │ {:<11} │",
2374                    p.name, p.version, published, new_crate, auth_type, ownership, dry_run
2375                );
2376            }
2377            println!(
2378                "└─────────────────────┴─────────┴──────────┴──────────┴───────────────┴─────────────┴─────────────┘"
2379            );
2380            println!();
2381
2382            // Display dry-run failures if any
2383            let failed_packages: Vec<_> = rep
2384                .packages
2385                .iter()
2386                .filter(|p| !p.dry_run_passed && p.dry_run_output.is_some())
2387                .collect();
2388
2389            if !failed_packages.is_empty() {
2390                println!("Dry-run Failures:");
2391                println!("-----------------");
2392                for p in failed_packages {
2393                    println!("Package: {}@{}", p.name, p.version);
2394                    println!("{}", p.dry_run_output.as_ref().unwrap());
2395                    println!();
2396                }
2397            } else if rep.finishability == Finishability::Failed && rep.dry_run_output.is_some() {
2398                // Check if workspace dry-run failed
2399                println!("Workspace Dry-run Failure:");
2400                println!("--------------------------");
2401                println!("{}", rep.dry_run_output.as_ref().unwrap());
2402                println!();
2403            }
2404
2405            // Summary
2406            let total = rep.packages.len();
2407            let already_published = rep.packages.iter().filter(|p| p.already_published).count();
2408            let new_crates = rep.packages.iter().filter(|p| p.is_new_crate).count();
2409            let ownership_verified = rep.packages.iter().filter(|p| p.ownership_verified).count();
2410            let dry_run_passed = rep.packages.iter().filter(|p| p.dry_run_passed).count();
2411
2412            println!("Summary:");
2413            println!("  Total packages: {}", total);
2414            println!("  Already published: {}", already_published);
2415            println!("  New crates: {}", new_crates);
2416            println!("  Ownership verified: {}", ownership_verified);
2417            println!("  Dry-run passed: {}", dry_run_passed);
2418            if let Some(estimate) = &rep.estimated_publish_duration {
2419                println!(
2420                    "  Estimated registry pacing: at least {}",
2421                    humantime::format_duration(estimate.minimum_registry_pacing)
2422                );
2423                println!(
2424                    "    profile={} first_publish={} updates={}",
2425                    estimate.registry_profile, estimate.first_publish_count, estimate.update_count
2426                );
2427            }
2428            println!();
2429
2430            print_preflight_proof_explanation(rep, total, dry_run_passed);
2431
2432            // What to do next guidance
2433            println!("What to do next:");
2434            println!("-----------------");
2435            match rep.finishability {
2436                Finishability::Proven => {
2437                    println!(
2438                        "\x1b[32m✓ All local preflight checks passed. Next: shipper publish\x1b[0m"
2439                    );
2440                }
2441                Finishability::NotProven => {
2442                    println!(
2443                        "\x1b[33m⚠ Preflight did not prove every release prerequisite.\x1b[0m"
2444                    );
2445                    println!(
2446                        "  - configure registry auth or Trusted Publishing if ownership is unverified"
2447                    );
2448                    println!("  - rerun `shipper preflight`");
2449                    println!(
2450                        "  - if you accept the uncertainty, run `shipper publish` with an explicit policy choice"
2451                    );
2452                }
2453                Finishability::Failed => {
2454                    println!(
2455                        "\x1b[31m✗ Preflight failed. Fix the failed checks above, then rerun `shipper preflight`.\x1b[0m"
2456                    );
2457                }
2458            }
2459        }
2460    }
2461}
2462
2463fn build_preflight_json_report(rep: &PreflightReport) -> PreflightJsonReport<'_> {
2464    let total = rep.packages.len();
2465    let dry_run_passed = rep.packages.iter().filter(|p| p.dry_run_passed).count();
2466    let dry_run_failed = rep
2467        .packages
2468        .iter()
2469        .filter(|p| !p.dry_run_passed)
2470        .collect::<Vec<_>>();
2471    let ownership_unverified = rep
2472        .packages
2473        .iter()
2474        .filter(|p| !p.ownership_verified)
2475        .collect::<Vec<_>>();
2476
2477    let mut proofs = Vec::new();
2478    if dry_run_failed.is_empty() {
2479        proofs.push(PreflightEvidenceItem {
2480            id: "local_dry_run",
2481            status: "passed",
2482            summary: format!(
2483                "Local package dry-run passed for {} of {} {}.",
2484                dry_run_passed,
2485                total,
2486                package_noun(total)
2487            ),
2488            packages: rep.packages.iter().map(package_ref).collect(),
2489        });
2490    } else if dry_run_passed > 0 {
2491        proofs.push(PreflightEvidenceItem {
2492            id: "local_dry_run_partial",
2493            status: "partial",
2494            summary: format!(
2495                "Local package dry-run passed for {} of {} {}.",
2496                dry_run_passed,
2497                total,
2498                package_noun(total)
2499            ),
2500            packages: rep
2501                .packages
2502                .iter()
2503                .filter(|p| p.dry_run_passed)
2504                .map(package_ref)
2505                .collect(),
2506        });
2507    }
2508
2509    proofs.push(PreflightEvidenceItem {
2510        id: "registry_version_checks",
2511        status: "completed",
2512        summary: format!(
2513            "Registry version/new-crate checks completed for {} {}.",
2514            total,
2515            package_noun(total)
2516        ),
2517        packages: rep.packages.iter().map(package_ref).collect(),
2518    });
2519
2520    if let Some(estimate) = &rep.estimated_publish_duration {
2521        proofs.push(PreflightEvidenceItem {
2522            id: "registry_pacing_estimate",
2523            status: "completed",
2524            summary: format!(
2525                "Registry pacing estimate generated from the {} profile.",
2526                estimate.registry_profile
2527            ),
2528            packages: Vec::new(),
2529        });
2530    }
2531
2532    let mut gaps = Vec::new();
2533    if !ownership_unverified.is_empty() {
2534        gaps.push(PreflightEvidenceItem {
2535            id: "ownership_unverified",
2536            status: "not_proven",
2537            summary: format!(
2538                "Ownership was not verified for {} of {} {}.",
2539                ownership_unverified.len(),
2540                total,
2541                package_noun(total)
2542            ),
2543            packages: ownership_unverified
2544                .iter()
2545                .copied()
2546                .map(package_ref)
2547                .collect(),
2548        });
2549    }
2550    if let Some(gap) = preflight_auth_gap(rep) {
2551        gaps.push(gap);
2552    }
2553
2554    let failed_checks = dry_run_failed
2555        .iter()
2556        .copied()
2557        .map(|package| PreflightEvidenceItem {
2558            id: "local_dry_run",
2559            status: "failed",
2560            summary: format!("Dry-run failed for {}.", package_ref(package)),
2561            packages: vec![package_ref(package)],
2562        })
2563        .collect();
2564
2565    let live_release_evidence = vec![PreflightEvidenceItem {
2566        id: "registry_acceptance_visibility",
2567        status: "pending_publish",
2568        summary:
2569            "Registry acceptance and post-publish visibility are recorded during publish/resume."
2570                .to_string(),
2571        packages: rep.packages.iter().map(package_ref).collect(),
2572    }];
2573
2574    PreflightJsonReport {
2575        schema_version: "shipper.preflight.v1",
2576        report: rep,
2577        proofs,
2578        gaps,
2579        failed_checks,
2580        live_release_evidence,
2581        registry_profile: rep.estimated_publish_duration.as_ref().map(|estimate| {
2582            PreflightRegistryProfileReport {
2583                name: estimate.registry_profile.clone(),
2584                first_publish_count: estimate.first_publish_count,
2585                update_count: estimate.update_count,
2586                minimum_registry_pacing: humantime::format_duration(
2587                    estimate.minimum_registry_pacing,
2588                )
2589                .to_string(),
2590                notes: estimate.notes.clone(),
2591            }
2592        }),
2593        artifacts: vec![PreflightArtifactReport {
2594            kind: "preflight_json_stdout",
2595            path: None,
2596            description: "This JSON document is the preflight evidence artifact when captured by CI.",
2597        }],
2598    }
2599}
2600
2601fn print_preflight_proof_explanation(rep: &PreflightReport, total: usize, dry_run_passed: usize) {
2602    let dry_run_failed = rep
2603        .packages
2604        .iter()
2605        .filter(|package| !package.dry_run_passed)
2606        .collect::<Vec<_>>();
2607    let ownership_unverified = rep
2608        .packages
2609        .iter()
2610        .filter(|package| !package.ownership_verified)
2611        .collect::<Vec<_>>();
2612
2613    println!("Proof explanation:");
2614    println!("  Proven now:");
2615    println!(
2616        "    - local package dry-run passed for {} of {} {}.",
2617        dry_run_passed,
2618        total,
2619        package_noun(total)
2620    );
2621    println!(
2622        "    - registry version/new-crate checks completed for {} {}.",
2623        total,
2624        package_noun(total)
2625    );
2626    if let Some(estimate) = &rep.estimated_publish_duration {
2627        println!(
2628            "    - registry pacing estimate generated from the {} profile.",
2629            estimate.registry_profile
2630        );
2631    }
2632
2633    println!("  Proof gaps:");
2634    if ownership_unverified.is_empty() {
2635        println!("    - none from local preflight.");
2636    } else {
2637        println!(
2638            "    - ownership was not verified for {} of {} {}: {}.",
2639            ownership_unverified.len(),
2640            total,
2641            package_noun(total),
2642            package_refs(ownership_unverified.iter().copied())
2643        );
2644    }
2645    if let Some(gap) = preflight_auth_gap(rep) {
2646        println!("    - {}", evidence_bullet(&gap.summary));
2647    }
2648
2649    println!("  Failed checks:");
2650    if dry_run_failed.is_empty() {
2651        println!("    - none.");
2652    } else {
2653        println!(
2654            "    - dry-run failed for {} of {} {}: {}.",
2655            dry_run_failed.len(),
2656            total,
2657            package_noun(total),
2658            package_refs(dry_run_failed.iter().copied())
2659        );
2660    }
2661
2662    println!("  Live-release evidence:");
2663    println!(
2664        "    - registry acceptance and post-publish visibility are recorded during publish/resume."
2665    );
2666    println!();
2667}
2668
2669fn package_refs<'a>(packages: impl Iterator<Item = &'a PreflightPackage>) -> String {
2670    packages.map(package_ref).collect::<Vec<_>>().join(", ")
2671}
2672
2673fn package_ref(package: &PreflightPackage) -> String {
2674    format!("{}@{}", package.name, package.version)
2675}
2676
2677fn evidence_bullet(summary: &str) -> String {
2678    let mut chars = summary.chars();
2679    let Some(first) = chars.next() else {
2680        return String::new();
2681    };
2682    let mut bullet = String::new();
2683    bullet.push(first.to_ascii_lowercase());
2684    bullet.extend(chars);
2685    bullet
2686}
2687
2688fn preflight_auth_gap(rep: &PreflightReport) -> Option<PreflightEvidenceItem> {
2689    if rep.token_detected {
2690        return None;
2691    }
2692
2693    let has_trusted_context = rep.packages.iter().any(|package| {
2694        matches!(
2695            package.auth_type,
2696            Some(shipper_core::types::AuthType::TrustedPublishing)
2697        )
2698    });
2699    let has_partial_trusted_context = rep.packages.iter().any(|package| {
2700        matches!(
2701            package.auth_type,
2702            Some(shipper_core::types::AuthType::Unknown)
2703        )
2704    });
2705
2706    if has_trusted_context {
2707        Some(PreflightEvidenceItem {
2708            id: "trusted_publishing_token_not_minted",
2709            status: "not_proven",
2710            summary: "Trusted Publishing OIDC context was detected, but no short-lived registry token was minted into Cargo auth before preflight.".to_string(),
2711            packages: Vec::new(),
2712        })
2713    } else if has_partial_trusted_context {
2714        Some(PreflightEvidenceItem {
2715            id: "trusted_publishing_oidc_incomplete",
2716            status: "not_proven",
2717            summary: "Trusted Publishing OIDC environment is incomplete; both GitHub OIDC request variables are required before a registry token can be minted.".to_string(),
2718            packages: Vec::new(),
2719        })
2720    } else {
2721        Some(PreflightEvidenceItem {
2722            id: "registry_auth_missing",
2723            status: "not_proven",
2724            summary: "No registry token or Trusted Publishing context was detected.".to_string(),
2725            packages: Vec::new(),
2726        })
2727    }
2728}
2729
2730fn package_noun(count: usize) -> &'static str {
2731    if count == 1 { "package" } else { "packages" }
2732}
2733
2734#[derive(Serialize)]
2735struct PublishJsonReport<'a> {
2736    schema_version: &'static str,
2737    command: &'static str,
2738    registry: String,
2739    plan_id: &'a str,
2740    state_dir: String,
2741    published: usize,
2742    pending: usize,
2743    failed: usize,
2744    ambiguous: usize,
2745    uploaded: usize,
2746    skipped: usize,
2747    packages: Vec<CommandJsonPackageReport>,
2748    artifacts: CommandJsonArtifacts,
2749    receipt: &'a shipper_core::types::Receipt,
2750}
2751
2752#[derive(Serialize)]
2753struct ResumeJsonReport<'a> {
2754    schema_version: &'static str,
2755    command: &'static str,
2756    safe_to_resume: bool,
2757    registry: String,
2758    plan_id: &'a str,
2759    state_dir: String,
2760    published: usize,
2761    pending: usize,
2762    failed: usize,
2763    ambiguous: usize,
2764    uploaded: usize,
2765    skipped: usize,
2766    next_package: Option<String>,
2767    packages: Vec<CommandJsonPackageReport>,
2768    artifacts: CommandJsonArtifacts,
2769    receipt: &'a shipper_core::types::Receipt,
2770}
2771
2772struct CommandJsonPackageCounts {
2773    published: usize,
2774    pending: usize,
2775    failed: usize,
2776    ambiguous: usize,
2777    uploaded: usize,
2778    skipped: usize,
2779    next_package: Option<String>,
2780}
2781
2782#[derive(Serialize)]
2783struct PlanYankJsonReport<'a> {
2784    schema_version: &'static str,
2785    command: &'static str,
2786    #[serde(flatten)]
2787    plan: &'a shipper_core::engine::plan_yank::YankPlan,
2788}
2789
2790#[derive(Serialize)]
2791struct FixForwardJsonReport<'a> {
2792    schema_version: &'static str,
2793    command: &'static str,
2794    #[serde(flatten)]
2795    plan: &'a shipper_core::engine::fix_forward::FixForwardPlan,
2796}
2797
2798#[derive(Serialize)]
2799struct CommandJsonPackageReport {
2800    name: String,
2801    version: String,
2802    state: &'static str,
2803    attempts: u32,
2804    reconciled: bool,
2805}
2806
2807#[derive(Serialize)]
2808struct CommandJsonArtifacts {
2809    state: CommandJsonArtifact,
2810    events: CommandJsonArtifact,
2811    receipt: CommandJsonArtifact,
2812    reconciliation: CommandJsonArtifact,
2813}
2814
2815#[derive(Serialize)]
2816struct CommandJsonArtifact {
2817    path: String,
2818    exists: bool,
2819}
2820
2821fn print_publish_output(
2822    receipt: &shipper_core::types::Receipt,
2823    workspace_root: &Path,
2824    state_dir: &Path,
2825    format: &str,
2826) -> Result<()> {
2827    if format == "json" {
2828        let report = build_publish_json_report(receipt, state_dir)?;
2829        let json = serde_json::to_string_pretty(&report)
2830            .context("failed to serialize publish JSON envelope")?;
2831        println!("{}", json);
2832        return Ok(());
2833    }
2834
2835    print_receipt(receipt, workspace_root, state_dir, format);
2836    Ok(())
2837}
2838
2839fn print_resume_output(
2840    receipt: &shipper_core::types::Receipt,
2841    workspace_root: &Path,
2842    state_dir: &Path,
2843    format: &str,
2844) -> Result<()> {
2845    if format == "json" {
2846        let report = build_resume_json_report(receipt, state_dir)?;
2847        let json = serde_json::to_string_pretty(&report)
2848            .context("failed to serialize resume JSON envelope")?;
2849        println!("{}", json);
2850        return Ok(());
2851    }
2852
2853    print_receipt(receipt, workspace_root, state_dir, format);
2854    Ok(())
2855}
2856
2857fn build_publish_json_report<'a>(
2858    receipt: &'a shipper_core::types::Receipt,
2859    state_dir: &Path,
2860) -> Result<PublishJsonReport<'a>> {
2861    let reconciled = reconciled_packages(state_dir)?;
2862    let packages = command_package_reports(receipt, &reconciled);
2863    let counts = command_package_counts(receipt);
2864
2865    Ok(PublishJsonReport {
2866        schema_version: "shipper.publish.v1",
2867        command: "publish",
2868        registry: receipt.registry.name.clone(),
2869        plan_id: &receipt.plan_id,
2870        state_dir: state_dir.display().to_string(),
2871        published: counts.published,
2872        pending: counts.pending,
2873        failed: counts.failed,
2874        ambiguous: counts.ambiguous,
2875        uploaded: counts.uploaded,
2876        skipped: counts.skipped,
2877        packages,
2878        artifacts: command_json_artifacts(state_dir),
2879        receipt,
2880    })
2881}
2882
2883fn build_resume_json_report<'a>(
2884    receipt: &'a shipper_core::types::Receipt,
2885    state_dir: &Path,
2886) -> Result<ResumeJsonReport<'a>> {
2887    let reconciled = reconciled_packages(state_dir)?;
2888    let packages = command_package_reports(receipt, &reconciled);
2889    let counts = command_package_counts(receipt);
2890    let safe_to_resume = counts.failed == 0 && counts.ambiguous == 0;
2891
2892    Ok(ResumeJsonReport {
2893        schema_version: "shipper.resume.v1",
2894        command: "resume",
2895        safe_to_resume,
2896        registry: receipt.registry.name.clone(),
2897        plan_id: &receipt.plan_id,
2898        state_dir: state_dir.display().to_string(),
2899        published: counts.published,
2900        pending: counts.pending,
2901        failed: counts.failed,
2902        ambiguous: counts.ambiguous,
2903        uploaded: counts.uploaded,
2904        skipped: counts.skipped,
2905        next_package: counts.next_package,
2906        packages,
2907        artifacts: command_json_artifacts(state_dir),
2908        receipt,
2909    })
2910}
2911
2912fn command_package_counts(receipt: &shipper_core::types::Receipt) -> CommandJsonPackageCounts {
2913    let mut counts = CommandJsonPackageCounts {
2914        published: 0,
2915        pending: 0,
2916        failed: 0,
2917        ambiguous: 0,
2918        uploaded: 0,
2919        skipped: 0,
2920        next_package: None,
2921    };
2922
2923    for package in &receipt.packages {
2924        match &package.state {
2925            PackageState::Pending => {
2926                counts.pending += 1;
2927                counts
2928                    .next_package
2929                    .get_or_insert_with(|| package.name.clone());
2930            }
2931            PackageState::Uploaded => {
2932                counts.uploaded += 1;
2933                counts
2934                    .next_package
2935                    .get_or_insert_with(|| package.name.clone());
2936            }
2937            PackageState::Published => {
2938                counts.published += 1;
2939            }
2940            PackageState::Skipped { .. } => {
2941                counts.skipped += 1;
2942            }
2943            PackageState::Failed { .. } => {
2944                counts.failed += 1;
2945                counts
2946                    .next_package
2947                    .get_or_insert_with(|| package.name.clone());
2948            }
2949            PackageState::Ambiguous { .. } => {
2950                counts.ambiguous += 1;
2951                counts
2952                    .next_package
2953                    .get_or_insert_with(|| package.name.clone());
2954            }
2955        }
2956    }
2957
2958    counts
2959}
2960
2961fn command_package_reports(
2962    receipt: &shipper_core::types::Receipt,
2963    reconciled: &BTreeSet<(String, String)>,
2964) -> Vec<CommandJsonPackageReport> {
2965    receipt
2966        .packages
2967        .iter()
2968        .map(|package| CommandJsonPackageReport {
2969            name: package.name.clone(),
2970            version: package.version.clone(),
2971            state: package_state_name(&package.state),
2972            attempts: package.attempts,
2973            reconciled: reconciled.contains(&(package.name.clone(), package.version.clone())),
2974        })
2975        .collect()
2976}
2977
2978fn command_json_artifacts(state_dir: &Path) -> CommandJsonArtifacts {
2979    CommandJsonArtifacts {
2980        state: json_artifact(state_dir.join(shipper_core::state::execution_state::STATE_FILE)),
2981        events: json_artifact(state_dir.join(shipper_core::state::events::EVENTS_FILE)),
2982        receipt: json_artifact(state_dir.join(shipper_core::state::execution_state::RECEIPT_FILE)),
2983        reconciliation: json_artifact(
2984            state_dir.join(shipper_core::state::execution_state::RECONCILIATION_FILE),
2985        ),
2986    }
2987}
2988
2989fn json_artifact(path: PathBuf) -> CommandJsonArtifact {
2990    CommandJsonArtifact {
2991        exists: path.exists(),
2992        path: path.display().to_string(),
2993    }
2994}
2995
2996fn reconciled_packages(state_dir: &Path) -> Result<BTreeSet<(String, String)>> {
2997    let path = shipper_core::state::execution_state::reconciliation_path(state_dir);
2998    if !path.exists() {
2999        return Ok(BTreeSet::new());
3000    }
3001
3002    let raw = std::fs::read_to_string(&path)
3003        .with_context(|| format!("failed to read reconciliation report {}", path.display()))?;
3004    let report: shipper_core::types::ReconciliationReport = serde_json::from_str(&raw)
3005        .with_context(|| format!("failed to parse reconciliation report {}", path.display()))?;
3006
3007    Ok(report
3008        .records
3009        .into_iter()
3010        .map(|record| (record.name, record.version))
3011        .collect())
3012}
3013
3014fn package_state_name(state: &PackageState) -> &'static str {
3015    match state {
3016        PackageState::Pending => "pending",
3017        PackageState::Uploaded => "uploaded",
3018        PackageState::Published => "published",
3019        PackageState::Skipped { .. } => "skipped",
3020        PackageState::Failed { .. } => "failed",
3021        PackageState::Ambiguous { .. } => "ambiguous",
3022    }
3023}
3024
3025fn print_receipt(
3026    receipt: &shipper_core::types::Receipt,
3027    workspace_root: &Path,
3028    state_dir: &Path,
3029    format: &str,
3030) {
3031    match format {
3032        "json" => {
3033            let json = serde_json::to_string_pretty(receipt).expect("serialize receipt");
3034            println!("{}", json);
3035        }
3036        _ => {
3037            println!("plan_id: {}", receipt.plan_id);
3038            println!(
3039                "registry: {} ({})",
3040                receipt.registry.name, receipt.registry.api_base
3041            );
3042
3043            let abs_state = if state_dir.is_absolute() {
3044                state_dir.to_path_buf()
3045            } else {
3046                workspace_root.join(state_dir)
3047            };
3048
3049            println!(
3050                "state:   {}/{}",
3051                abs_state.display(),
3052                shipper_core::state::execution_state::STATE_FILE
3053            );
3054            println!(
3055                "receipt: {}/{}",
3056                abs_state.display(),
3057                shipper_core::state::execution_state::RECEIPT_FILE
3058            );
3059            println!(
3060                "events:   {}/{}",
3061                abs_state.display(),
3062                shipper_core::state::events::EVENTS_FILE
3063            );
3064            println!();
3065
3066            for p in &receipt.packages {
3067                println!(
3068                    "{}@{}: {:?} (attempts={}, {}ms)",
3069                    p.name, p.version, p.state, p.attempts, p.duration_ms
3070                );
3071                // Show evidence summary
3072                if !p.evidence.attempts.is_empty() {
3073                    println!("  Evidence:");
3074                    for attempt in &p.evidence.attempts {
3075                        println!(
3076                            "    Attempt {}: exit={}, duration={}ms",
3077                            attempt.attempt_number,
3078                            attempt.exit_code,
3079                            attempt.duration.as_millis()
3080                        );
3081                        if !attempt.stdout_tail.is_empty() {
3082                            println!(
3083                                "      stdout (last {} lines):",
3084                                attempt.stdout_tail.lines().count()
3085                            );
3086                            for line in attempt.stdout_tail.lines().take(5) {
3087                                println!("        {}", line);
3088                            }
3089                        }
3090                        if !attempt.stderr_tail.is_empty() {
3091                            println!(
3092                                "      stderr (last {} lines):",
3093                                attempt.stderr_tail.lines().count()
3094                            );
3095                            for line in attempt.stderr_tail.lines().take(5) {
3096                                println!("        {}", line);
3097                            }
3098                        }
3099                    }
3100                }
3101                if !p.evidence.readiness_checks.is_empty() {
3102                    println!(
3103                        "  Readiness checks: {} attempts",
3104                        p.evidence.readiness_checks.len()
3105                    );
3106                    for check in &p.evidence.readiness_checks {
3107                        println!(
3108                            "    Poll {}: visible={}, delay_before={}ms",
3109                            check.attempt,
3110                            check.visible,
3111                            check.delay_before.as_millis()
3112                        );
3113                    }
3114                }
3115            }
3116        }
3117    }
3118}
3119
3120fn run_inspect_events(
3121    ws: &plan::PlannedWorkspace,
3122    opts: &RuntimeOptions,
3123    format: &str,
3124    follow: bool,
3125) -> Result<()> {
3126    let state_dir = if opts.state_dir.is_absolute() {
3127        opts.state_dir.clone()
3128    } else {
3129        ws.workspace_root.join(&opts.state_dir)
3130    };
3131
3132    if follow {
3133        return follow_authoritative_event_log(&state_dir, format);
3134    }
3135
3136    let event_logs = discover_event_logs(&state_dir)?;
3137    if event_logs.is_empty() {
3138        println!("No event logs found under {}", state_dir.display());
3139        return Ok(());
3140    }
3141
3142    for (idx, events_path) in event_logs.iter().enumerate() {
3143        let event_log = shipper_core::state::events::EventLog::read_from_file(events_path)
3144            .with_context(|| format!("failed to read event log from {}", events_path.display()))?;
3145
3146        if format != "json" {
3147            println!("Event log: {}", events_path.display());
3148            println!();
3149        }
3150
3151        for event in event_log.all_events() {
3152            let json = serde_json::to_string(event).expect("serialize event");
3153            println!("{}", json);
3154        }
3155
3156        if format != "json" && idx + 1 != event_logs.len() {
3157            println!();
3158        }
3159    }
3160
3161    Ok(())
3162}
3163
3164fn follow_authoritative_event_log(state_dir: &Path, format: &str) -> Result<()> {
3165    let events_path = shipper_core::state::events::events_path(state_dir);
3166    if format != "json" {
3167        println!("Event log: {}", events_path.display());
3168        if !events_path.exists() {
3169            println!("Waiting for events...");
3170        }
3171        println!("Press Ctrl+C to stop.");
3172        println!();
3173    }
3174
3175    let mut offset = 0;
3176    let stdout = std::io::stdout();
3177    let mut out = stdout.lock();
3178    loop {
3179        offset = write_event_lines_since(&events_path, offset, format, &mut out)?;
3180        out.flush().context("failed to flush event output")?;
3181        std::thread::sleep(Duration::from_millis(500));
3182    }
3183}
3184
3185fn write_event_lines_since<W: Write>(
3186    events_path: &Path,
3187    offset: u64,
3188    format: &str,
3189    out: &mut W,
3190) -> Result<u64> {
3191    if !events_path.exists() {
3192        return Ok(offset);
3193    }
3194
3195    let len = std::fs::metadata(events_path)
3196        .with_context(|| format!("failed to stat event log {}", events_path.display()))?
3197        .len();
3198    let mut next_offset = offset.min(len);
3199    let mut file = std::fs::File::open(events_path)
3200        .with_context(|| format!("failed to open event log {}", events_path.display()))?;
3201    file.seek(SeekFrom::Start(next_offset))
3202        .with_context(|| format!("failed to seek event log {}", events_path.display()))?;
3203
3204    let mut reader = BufReader::new(file);
3205    let mut line = String::new();
3206    loop {
3207        line.clear();
3208        let line_start = next_offset;
3209        let read = reader
3210            .read_line(&mut line)
3211            .with_context(|| format!("failed to read event log {}", events_path.display()))?;
3212        if read == 0 {
3213            break;
3214        }
3215        if !line.ends_with('\n') {
3216            next_offset = line_start;
3217            break;
3218        }
3219        next_offset += read as u64;
3220        let trimmed = line.trim_end_matches(['\r', '\n']);
3221        if trimmed.is_empty() {
3222            continue;
3223        }
3224        let event: shipper_core::types::PublishEvent = serde_json::from_str(trimmed)
3225            .with_context(|| format!("failed to parse event JSON from line: {}", trimmed))?;
3226        write_follow_event_line(&event, format, out)?;
3227    }
3228
3229    Ok(next_offset)
3230}
3231
3232fn write_follow_event_line<W: Write>(
3233    event: &shipper_core::types::PublishEvent,
3234    format: &str,
3235    out: &mut W,
3236) -> Result<()> {
3237    if format == "json" {
3238        serde_json::to_writer(&mut *out, event).context("failed to serialize event")?;
3239        out.write_all(b"\n")
3240            .context("failed to write event output")?;
3241        return Ok(());
3242    }
3243
3244    let report = status_watch_event_report(event);
3245    writeln!(
3246        out,
3247        "{} {} {} - {}",
3248        report.timestamp, report.package, report.kind, report.summary
3249    )
3250    .context("failed to write event output")?;
3251    Ok(())
3252}
3253
3254fn discover_event_logs(state_dir: &Path) -> Result<Vec<PathBuf>> {
3255    let mut paths = Vec::new();
3256    let authoritative = shipper_core::state::events::events_path(state_dir);
3257    if authoritative.exists() {
3258        paths.push(authoritative);
3259    }
3260
3261    let mut seen = BTreeSet::new();
3262    for path in shipper_core::state::events::preflight_only_events_paths(state_dir)? {
3263        if seen.insert(path.clone()) {
3264            paths.push(path);
3265        }
3266    }
3267
3268    Ok(paths)
3269}
3270
3271fn run_inspect_receipt(
3272    ws: &plan::PlannedWorkspace,
3273    opts: &RuntimeOptions,
3274    format: &str,
3275) -> Result<()> {
3276    let state_dir = if opts.state_dir.is_absolute() {
3277        opts.state_dir.clone()
3278    } else {
3279        ws.workspace_root.join(&opts.state_dir)
3280    };
3281
3282    let receipt_path = shipper_core::state::execution_state::receipt_path(&state_dir);
3283    let content = std::fs::read_to_string(&receipt_path)
3284        .with_context(|| format!("failed to read receipt from {}", receipt_path.display()))?;
3285
3286    let receipt: shipper_core::types::Receipt = serde_json::from_str(&content)
3287        .with_context(|| format!("failed to parse receipt from {}", receipt_path.display()))?;
3288
3289    if format == "json" {
3290        let json = serde_json::to_string_pretty(&receipt).expect("serialize receipt");
3291        println!("{}", json);
3292        return Ok(());
3293    }
3294
3295    // Display receipt in human-readable format
3296    println!("Receipt");
3297    println!("=======");
3298    println!();
3299    println!("Plan ID: {}", receipt.plan_id);
3300    println!(
3301        "Registry: {} ({})",
3302        receipt.registry.name, receipt.registry.api_base
3303    );
3304    println!(
3305        "Started: {}",
3306        receipt.started_at.format("%Y-%m-%dT%H:%M:%SZ")
3307    );
3308    println!(
3309        "Finished: {}",
3310        receipt.finished_at.format("%Y-%m-%dT%H:%M:%SZ")
3311    );
3312    println!(
3313        "Duration: {}ms",
3314        (receipt.finished_at - receipt.started_at).num_milliseconds()
3315    );
3316    println!();
3317
3318    // Display Git context if available
3319    if let Some(git) = &receipt.git_context {
3320        println!("Git Context:");
3321        println!("------------");
3322        if let Some(commit) = &git.commit {
3323            println!("  Commit: {}", commit);
3324        }
3325        if let Some(branch) = &git.branch {
3326            println!("  Branch: {}", branch);
3327        }
3328        if let Some(tag) = &git.tag {
3329            println!("  Tag: {}", tag);
3330        }
3331        if let Some(dirty) = git.dirty {
3332            println!("  Dirty: {}", if dirty { "Yes" } else { "No" });
3333        }
3334        println!();
3335    }
3336
3337    // Display environment fingerprint
3338    println!("Environment:");
3339    println!("------------");
3340    println!("  Shipper: {}", receipt.environment.shipper_version);
3341    if let Some(cargo) = &receipt.environment.cargo_version {
3342        println!("  Cargo: {}", cargo);
3343    }
3344    if let Some(rust) = &receipt.environment.rust_version {
3345        println!("  Rust: {}", rust);
3346    }
3347    println!("  OS: {}", receipt.environment.os);
3348    println!("  Arch: {}", receipt.environment.arch);
3349    println!();
3350
3351    // Display packages
3352    println!("Packages:");
3353    println!("---------");
3354    for p in &receipt.packages {
3355        let state_str = match &p.state {
3356            shipper_core::types::PackageState::Published => "\x1b[32mPublished\x1b[0m",
3357            shipper_core::types::PackageState::Pending => "Pending",
3358            shipper_core::types::PackageState::Uploaded => "\x1b[33mUploaded\x1b[0m",
3359            shipper_core::types::PackageState::Skipped { reason } => {
3360                &format!("Skipped: {}", reason)
3361            }
3362            shipper_core::types::PackageState::Failed { class, message } => {
3363                &format!("\x1b[31mFailed ({:?}): {}\x1b[0m", class, message)
3364            }
3365            shipper_core::types::PackageState::Ambiguous { message } => {
3366                &format!("\x1b[33mAmbiguous: {}\x1b[0m", message)
3367            }
3368        };
3369        println!(
3370            "  {}@{}: {} (attempts={}, {}ms)",
3371            p.name, p.version, state_str, p.attempts, p.duration_ms
3372        );
3373    }
3374
3375    Ok(())
3376}
3377
3378#[derive(Debug, Serialize)]
3379struct StatusReport {
3380    schema_version: &'static str,
3381    plan_id: String,
3382    workspace_root: String,
3383    registries: Vec<StatusRegistryReport>,
3384}
3385
3386#[derive(Debug, Serialize)]
3387struct StatusRegistryReport {
3388    name: String,
3389    api_base: String,
3390    #[serde(skip_serializing_if = "Option::is_none")]
3391    index_base: Option<String>,
3392    packages: Vec<StatusPackageReport>,
3393}
3394
3395#[derive(Debug, Serialize)]
3396struct StatusPackageReport {
3397    name: String,
3398    version: String,
3399    status: &'static str,
3400    exists: bool,
3401}
3402
3403fn build_status_registry_report(
3404    ws: &plan::PlannedWorkspace,
3405    reporter: &mut dyn Reporter,
3406) -> Result<StatusRegistryReport> {
3407    reporter.info("initializing registry client...");
3408    let reg = shipper_core::registry::RegistryClient::new(ws.plan.registry.clone())?;
3409
3410    let mut packages = Vec::new();
3411    for p in &ws.plan.packages {
3412        let exists = reg.version_exists(&p.name, &p.version)?;
3413        packages.push(StatusPackageReport {
3414            name: p.name.clone(),
3415            version: p.version.clone(),
3416            status: if exists { "published" } else { "missing" },
3417            exists,
3418        });
3419    }
3420
3421    Ok(StatusRegistryReport {
3422        name: ws.plan.registry.name.clone(),
3423        api_base: ws.plan.registry.api_base.clone(),
3424        index_base: ws.plan.registry.index_base.clone(),
3425        packages,
3426    })
3427}
3428
3429fn write_status_report(report: &StatusReport, format: &str) -> Result<()> {
3430    if format == "json" {
3431        let json = serde_json::to_string_pretty(report).context("serialize status report")?;
3432        println!("{json}");
3433        return Ok(());
3434    }
3435
3436    println!("plan_id: {}", report.plan_id);
3437    println!();
3438
3439    let multiple_registries = report.registries.len() > 1;
3440    for (idx, registry) in report.registries.iter().enumerate() {
3441        if multiple_registries {
3442            if idx > 0 {
3443                println!();
3444            }
3445            println!(
3446                "📊 Status for registry: {} ({})",
3447                registry.name, registry.api_base
3448            );
3449        }
3450        for package in &registry.packages {
3451            println!("{}@{}: {}", package.name, package.version, package.status);
3452        }
3453    }
3454
3455    Ok(())
3456}
3457
3458fn run_status_watch(
3459    ws: &plan::PlannedWorkspace,
3460    opts: &RuntimeOptions,
3461    format: &str,
3462) -> Result<()> {
3463    let state_dir = absolute_state_dir(ws, opts);
3464    let stdout = std::io::stdout();
3465    let mut first = true;
3466
3467    loop {
3468        if !first && format != "json" {
3469            println!();
3470        }
3471        first = false;
3472
3473        let report = build_status_watch_report(ws, &state_dir)?;
3474        {
3475            let mut out = stdout.lock();
3476            write_status_watch_report(&report, format, &mut out)?;
3477            out.flush().context("failed to flush status watch output")?;
3478        }
3479
3480        std::thread::sleep(Duration::from_millis(500));
3481    }
3482}
3483
3484fn absolute_state_dir(ws: &plan::PlannedWorkspace, opts: &RuntimeOptions) -> PathBuf {
3485    if opts.state_dir.is_absolute() {
3486        opts.state_dir.clone()
3487    } else {
3488        ws.workspace_root.join(&opts.state_dir)
3489    }
3490}
3491
3492#[derive(Debug, Serialize)]
3493struct StatusWatchReport {
3494    schema_version: &'static str,
3495    plan_id: String,
3496    state_dir: String,
3497    events_path: String,
3498    receipt_path: String,
3499    state_present: bool,
3500    event_count: usize,
3501    counts: StatusWatchCounts,
3502    current_package: Option<String>,
3503    last_event: Option<StatusWatchEventReport>,
3504    next_action: Option<StatusWatchNextAction>,
3505    packages: Vec<StatusWatchPackageReport>,
3506}
3507
3508#[derive(Debug, Default, Serialize)]
3509struct StatusWatchCounts {
3510    total: usize,
3511    pending: usize,
3512    uploaded: usize,
3513    published: usize,
3514    skipped: usize,
3515    failed: usize,
3516    ambiguous: usize,
3517}
3518
3519#[derive(Debug, Serialize)]
3520struct StatusWatchPackageReport {
3521    name: String,
3522    version: String,
3523    state: String,
3524    attempts: u32,
3525    last_updated_at: Option<String>,
3526}
3527
3528#[derive(Debug, Serialize)]
3529struct StatusWatchEventReport {
3530    timestamp: String,
3531    package: String,
3532    kind: &'static str,
3533    summary: String,
3534}
3535
3536#[derive(Debug, Serialize)]
3537struct StatusWatchNextAction {
3538    kind: &'static str,
3539    package: String,
3540    at: String,
3541    delay_ms: u64,
3542    summary: String,
3543}
3544
3545fn build_status_watch_report(
3546    ws: &plan::PlannedWorkspace,
3547    state_dir: &Path,
3548) -> Result<StatusWatchReport> {
3549    let state = shipper_core::state::execution_state::load_state(state_dir)?;
3550    let events_path = shipper_core::state::events::events_path(state_dir);
3551    let receipt_path = shipper_core::state::execution_state::receipt_path(state_dir);
3552    let events = read_status_watch_events(&events_path)
3553        .with_context(|| format!("failed to read event log from {}", events_path.display()))?;
3554
3555    let packages = build_status_watch_packages(ws, state.as_ref());
3556    let counts = status_watch_counts(&packages);
3557    let current_package = current_status_package(&events, state.as_ref(), &packages);
3558    let last_event = events.last().map(status_watch_event_report);
3559    let next_action = latest_status_watch_next_action(&events);
3560
3561    Ok(StatusWatchReport {
3562        schema_version: "shipper.status.watch.v1",
3563        plan_id: ws.plan.plan_id.clone(),
3564        state_dir: state_dir.display().to_string(),
3565        events_path: events_path.display().to_string(),
3566        receipt_path: receipt_path.display().to_string(),
3567        state_present: state.is_some(),
3568        event_count: events.len(),
3569        counts,
3570        current_package,
3571        last_event,
3572        next_action,
3573        packages,
3574    })
3575}
3576
3577fn read_status_watch_events(events_path: &Path) -> Result<Vec<PublishEvent>> {
3578    if !events_path.exists() {
3579        return Ok(Vec::new());
3580    }
3581
3582    let content = std::fs::read_to_string(events_path)
3583        .with_context(|| format!("failed to read event log {}", events_path.display()))?;
3584    let lines: Vec<&str> = content.lines().collect();
3585    let has_complete_tail = content.ends_with('\n');
3586    let mut events = Vec::new();
3587
3588    for (idx, line) in lines.iter().enumerate() {
3589        let trimmed = line.trim_end_matches(['\r', '\n']);
3590        if trimmed.is_empty() {
3591            continue;
3592        }
3593
3594        match serde_json::from_str::<PublishEvent>(trimmed) {
3595            Ok(event) => events.push(event),
3596            Err(err) => {
3597                // A live writer can leave a final JSONL line incomplete while
3598                // status is reading. Keep the last complete snapshot and retry
3599                // on the next watch tick instead of failing the operator view.
3600                if idx + 1 == lines.len() && !has_complete_tail {
3601                    break;
3602                }
3603                return Err(err)
3604                    .with_context(|| format!("failed to parse event JSON from line: {}", trimmed));
3605            }
3606        }
3607    }
3608
3609    Ok(events)
3610}
3611
3612fn build_status_watch_packages(
3613    ws: &plan::PlannedWorkspace,
3614    state: Option<&ExecutionState>,
3615) -> Vec<StatusWatchPackageReport> {
3616    ws.plan
3617        .packages
3618        .iter()
3619        .map(|planned| {
3620            let key = pkg_key(&planned.name, &planned.version);
3621            let progress = state
3622                .and_then(|state| state.packages.get(&key))
3623                .or_else(|| state.and_then(|state| state.packages.get(&planned.name)));
3624            StatusWatchPackageReport {
3625                name: planned.name.clone(),
3626                version: planned.version.clone(),
3627                state: progress
3628                    .map(|progress| package_state_label(&progress.state).to_string())
3629                    .unwrap_or_else(|| "pending".to_string()),
3630                attempts: progress.map(|progress| progress.attempts).unwrap_or(0),
3631                last_updated_at: progress.map(|progress| format_utc(progress.last_updated_at)),
3632            }
3633        })
3634        .collect()
3635}
3636
3637fn status_watch_counts(packages: &[StatusWatchPackageReport]) -> StatusWatchCounts {
3638    let mut counts = StatusWatchCounts {
3639        total: packages.len(),
3640        ..StatusWatchCounts::default()
3641    };
3642    for package in packages {
3643        match package.state.as_str() {
3644            "pending" => counts.pending += 1,
3645            "uploaded" => counts.uploaded += 1,
3646            "published" => counts.published += 1,
3647            "skipped" => counts.skipped += 1,
3648            "failed" => counts.failed += 1,
3649            "ambiguous" => counts.ambiguous += 1,
3650            _ => {}
3651        }
3652    }
3653    counts
3654}
3655
3656fn current_status_package(
3657    events: &[PublishEvent],
3658    state: Option<&ExecutionState>,
3659    packages: &[StatusWatchPackageReport],
3660) -> Option<String> {
3661    if let Some(state) = state {
3662        for package in &state.packages {
3663            let progress = package.1;
3664            if !matches!(
3665                progress.state,
3666                PackageState::Published
3667                    | PackageState::Skipped { .. }
3668                    | PackageState::Failed { .. }
3669            ) {
3670                return Some(format!("{}@{}", progress.name, progress.version));
3671            }
3672        }
3673        return None;
3674    }
3675
3676    if let Some(event) = latest_active_progress_event(events) {
3677        return Some(event.package.clone());
3678    }
3679
3680    packages
3681        .iter()
3682        .find(|package| {
3683            package.state != "published" && package.state != "skipped" && package.state != "failed"
3684        })
3685        .map(|package| format!("{}@{}", package.name, package.version))
3686}
3687
3688fn latest_active_progress_event(events: &[PublishEvent]) -> Option<&PublishEvent> {
3689    for event in events.iter().rev() {
3690        if !event.package.is_empty()
3691            && event.package != "workspace"
3692            && event_type_is_active_progress(&event.event_type)
3693        {
3694            return Some(event);
3695        }
3696        if event_type_clears_next_action(&event.event_type) {
3697            return None;
3698        }
3699    }
3700    None
3701}
3702
3703fn status_watch_event_report(event: &PublishEvent) -> StatusWatchEventReport {
3704    StatusWatchEventReport {
3705        timestamp: format_utc(event.timestamp),
3706        package: event.package.clone(),
3707        kind: event_type_name(&event.event_type),
3708        summary: summarize_event(event),
3709    }
3710}
3711
3712fn latest_status_watch_next_action(events: &[PublishEvent]) -> Option<StatusWatchNextAction> {
3713    for event in events.iter().rev() {
3714        if let Some(action) = status_watch_next_action(event) {
3715            return Some(action);
3716        }
3717        if event_type_clears_next_action(&event.event_type) {
3718            return None;
3719        }
3720    }
3721    None
3722}
3723
3724fn status_watch_next_action(event: &PublishEvent) -> Option<StatusWatchNextAction> {
3725    match &event.event_type {
3726        EventType::RetryScheduled {
3727            attempt,
3728            max_attempts,
3729            delay_ms,
3730            next_attempt_at,
3731            reason,
3732            ..
3733        }
3734        | EventType::RetryBackoffStarted {
3735            attempt,
3736            max_attempts,
3737            delay_ms,
3738            next_attempt_at,
3739            reason,
3740            ..
3741        } => Some(StatusWatchNextAction {
3742            kind: "retry",
3743            package: event.package.clone(),
3744            at: format_utc(*next_attempt_at),
3745            delay_ms: *delay_ms,
3746            summary: format!(
3747                "attempt {}/{} scheduled after {} ({:?})",
3748                attempt + 1,
3749                max_attempts,
3750                format_millis(*delay_ms),
3751                reason
3752            ),
3753        }),
3754        EventType::PublishWaiting {
3755            reason,
3756            delay_ms,
3757            until,
3758        } => Some(StatusWatchNextAction {
3759            kind: "wait",
3760            package: event.package.clone(),
3761            at: format_utc(*until),
3762            delay_ms: *delay_ms,
3763            summary: format!("{} for {}", reason, format_millis(*delay_ms)),
3764        }),
3765        EventType::ReadinessPollScheduled {
3766            attempt,
3767            delay_ms,
3768            next_poll_at,
3769        } => Some(StatusWatchNextAction {
3770            kind: "readiness_poll",
3771            package: event.package.clone(),
3772            at: format_utc(*next_poll_at),
3773            delay_ms: *delay_ms,
3774            summary: format!(
3775                "readiness poll {} scheduled after {}",
3776                attempt + 1,
3777                format_millis(*delay_ms)
3778            ),
3779        }),
3780        _ => None,
3781    }
3782}
3783
3784fn event_type_is_active_progress(event_type: &EventType) -> bool {
3785    matches!(
3786        event_type,
3787        EventType::PackageStarted { .. }
3788            | EventType::PackageAttempted { .. }
3789            | EventType::PackageOutput { .. }
3790            | EventType::PublishWaiting { .. }
3791            | EventType::RateLimitObserved { .. }
3792            | EventType::PublishReconciling { .. }
3793            | EventType::RetryBackoffStarted { .. }
3794            | EventType::RetryScheduled { .. }
3795            | EventType::ReadinessStarted { .. }
3796            | EventType::ReadinessPoll { .. }
3797            | EventType::ReadinessPollScheduled { .. }
3798    )
3799}
3800
3801fn event_type_clears_next_action(event_type: &EventType) -> bool {
3802    matches!(
3803        event_type,
3804        EventType::ExecutionFinished { .. }
3805            | EventType::PackageStarted { .. }
3806            | EventType::PackagePublished { .. }
3807            | EventType::PackageFailed { .. }
3808            | EventType::PackageSkipped { .. }
3809            | EventType::PublishReconciled { .. }
3810            | EventType::ReadinessComplete { .. }
3811            | EventType::ReadinessTimeout { .. }
3812    )
3813}
3814
3815fn write_status_watch_report<W: Write>(
3816    report: &StatusWatchReport,
3817    format: &str,
3818    out: &mut W,
3819) -> Result<()> {
3820    if format == "json" {
3821        serde_json::to_writer(&mut *out, report).context("failed to serialize status")?;
3822        out.write_all(b"\n")
3823            .context("failed to write status output")?;
3824        return Ok(());
3825    }
3826
3827    writeln!(out, "Status watch")?;
3828    writeln!(out, "============")?;
3829    writeln!(out, "plan_id: {}", report.plan_id)?;
3830    writeln!(out, "state_dir: {}", report.state_dir)?;
3831    writeln!(
3832        out,
3833        "state: {}",
3834        if report.state_present {
3835            "present"
3836        } else {
3837            "missing"
3838        }
3839    )?;
3840    writeln!(
3841        out,
3842        "events: {} ({} events)",
3843        report.events_path, report.event_count
3844    )?;
3845    writeln!(out, "receipt: {}", report.receipt_path)?;
3846    writeln!(
3847        out,
3848        "progress: published={} pending={} uploaded={} skipped={} failed={} ambiguous={} total={}",
3849        report.counts.published,
3850        report.counts.pending,
3851        report.counts.uploaded,
3852        report.counts.skipped,
3853        report.counts.failed,
3854        report.counts.ambiguous,
3855        report.counts.total
3856    )?;
3857
3858    if let Some(current) = &report.current_package {
3859        writeln!(out, "current: {}", current)?;
3860    } else {
3861        writeln!(out, "current: none")?;
3862    }
3863
3864    if let Some(last_event) = &report.last_event {
3865        writeln!(
3866            out,
3867            "last_event: {} {} {} - {}",
3868            last_event.timestamp, last_event.package, last_event.kind, last_event.summary
3869        )?;
3870    } else {
3871        writeln!(out, "last_event: none")?;
3872    }
3873
3874    if let Some(next_action) = &report.next_action {
3875        writeln!(
3876            out,
3877            "next: {} {} at {} - {}",
3878            next_action.kind, next_action.package, next_action.at, next_action.summary
3879        )?;
3880    } else {
3881        writeln!(out, "next: none scheduled")?;
3882    }
3883
3884    writeln!(out, "packages:")?;
3885    for package in &report.packages {
3886        writeln!(
3887            out,
3888            "  {}@{}: {} (attempts={})",
3889            package.name, package.version, package.state, package.attempts
3890        )?;
3891    }
3892
3893    Ok(())
3894}
3895
3896fn package_state_label(state: &PackageState) -> &'static str {
3897    match state {
3898        PackageState::Pending => "pending",
3899        PackageState::Uploaded => "uploaded",
3900        PackageState::Published => "published",
3901        PackageState::Skipped { .. } => "skipped",
3902        PackageState::Failed { .. } => "failed",
3903        PackageState::Ambiguous { .. } => "ambiguous",
3904    }
3905}
3906
3907fn event_type_name(event_type: &EventType) -> &'static str {
3908    match event_type {
3909        EventType::PlanCreated { .. } => "plan_created",
3910        EventType::ExecutionStarted => "execution_started",
3911        EventType::ExecutionFinished { .. } => "execution_finished",
3912        EventType::AuthEvidenceRecorded { .. } => "auth_evidence_recorded",
3913        EventType::PackageStarted { .. } => "package_started",
3914        EventType::PackageAttempted { .. } => "package_attempted",
3915        EventType::PackageOutput { .. } => "package_output",
3916        EventType::PackagePublished { .. } => "package_published",
3917        EventType::PackageFailed { .. } => "package_failed",
3918        EventType::PackageSkipped { .. } => "package_skipped",
3919        EventType::PublishWaiting { .. } => "publish_waiting",
3920        EventType::RateLimitObserved { .. } => "rate_limit_observed",
3921        EventType::PublishReconciling { .. } => "publish_reconciling",
3922        EventType::PublishReconciled { .. } => "publish_reconciled",
3923        EventType::StateEventDriftDetected { .. } => "state_event_drift_detected",
3924        EventType::PackageYanked { .. } => "package_yanked",
3925        EventType::RehearsalStarted { .. } => "rehearsal_started",
3926        EventType::RehearsalPackagePublished { .. } => "rehearsal_package_published",
3927        EventType::RehearsalPackageFailed { .. } => "rehearsal_package_failed",
3928        EventType::RehearsalComplete { .. } => "rehearsal_complete",
3929        EventType::RehearsalSmokeCheckStarted { .. } => "rehearsal_smoke_check_started",
3930        EventType::RehearsalSmokeCheckSucceeded { .. } => "rehearsal_smoke_check_succeeded",
3931        EventType::RehearsalSmokeCheckFailed { .. } => "rehearsal_smoke_check_failed",
3932        EventType::RetryBackoffStarted { .. } => "retry_backoff_started",
3933        EventType::RetryScheduled { .. } => "retry_scheduled",
3934        EventType::ReadinessStarted { .. } => "readiness_started",
3935        EventType::ReadinessPoll { .. } => "readiness_poll",
3936        EventType::ReadinessPollScheduled { .. } => "readiness_poll_scheduled",
3937        EventType::ReadinessComplete { .. } => "readiness_complete",
3938        EventType::ReadinessTimeout { .. } => "readiness_timeout",
3939        EventType::IndexReadinessStarted { .. } => "index_readiness_started",
3940        EventType::IndexReadinessCheck { .. } => "index_readiness_check",
3941        EventType::IndexReadinessComplete { .. } => "index_readiness_complete",
3942        EventType::PreflightStarted => "preflight_started",
3943        EventType::PreflightWorkspaceVerify { .. } => "preflight_workspace_verify",
3944        EventType::PreflightNewCrateDetected { .. } => "preflight_new_crate_detected",
3945        EventType::PreflightOwnershipCheck { .. } => "preflight_ownership_check",
3946        EventType::PreflightComplete { .. } => "preflight_complete",
3947    }
3948}
3949
3950fn summarize_event(event: &PublishEvent) -> String {
3951    match &event.event_type {
3952        EventType::ExecutionStarted => "execution started".to_string(),
3953        EventType::ExecutionFinished { result } => format!("execution finished: {:?}", result),
3954        EventType::PackageStarted { name, version } => {
3955            format!("started {}@{}", name, version)
3956        }
3957        EventType::PackagePublished { duration_ms } => {
3958            format!("published in {}", format_millis(*duration_ms))
3959        }
3960        EventType::PackageFailed { class, message } => format!("failed ({:?}): {}", class, message),
3961        EventType::PackageSkipped { reason } => format!("skipped: {}", reason),
3962        EventType::PublishWaiting {
3963            reason, delay_ms, ..
3964        } => {
3965            format!("waiting for {} ({})", reason, format_millis(*delay_ms))
3966        }
3967        EventType::RateLimitObserved {
3968            retry_after_ms,
3969            message,
3970            ..
3971        } => match retry_after_ms {
3972            Some(delay) => format!(
3973                "rate limit observed: {}; retry-after {}",
3974                message,
3975                format_millis(*delay)
3976            ),
3977            None => format!("rate limit observed: {}", message),
3978        },
3979        EventType::RetryScheduled {
3980            attempt,
3981            max_attempts,
3982            delay_ms,
3983            reason,
3984            ..
3985        } => format!(
3986            "retry attempt {}/{} scheduled after {} ({:?})",
3987            attempt + 1,
3988            max_attempts,
3989            format_millis(*delay_ms),
3990            reason
3991        ),
3992        EventType::RetryBackoffStarted {
3993            attempt,
3994            max_attempts,
3995            delay_ms,
3996            reason,
3997            ..
3998        } => format!(
3999            "retry backoff before attempt {}/{} for {} ({:?})",
4000            attempt + 1,
4001            max_attempts,
4002            format_millis(*delay_ms),
4003            reason
4004        ),
4005        EventType::ReadinessStarted { method } => format!("readiness started: {:?}", method),
4006        EventType::ReadinessPoll { attempt, visible } => {
4007            format!("readiness poll {} visible={}", attempt, visible)
4008        }
4009        EventType::ReadinessPollScheduled {
4010            attempt, delay_ms, ..
4011        } => format!(
4012            "readiness poll {} scheduled after {}",
4013            attempt + 1,
4014            format_millis(*delay_ms)
4015        ),
4016        EventType::ReadinessComplete {
4017            duration_ms,
4018            attempts,
4019        } => format!(
4020            "readiness complete after {} checks in {}",
4021            attempts,
4022            format_millis(*duration_ms)
4023        ),
4024        EventType::ReadinessTimeout { max_wait_ms } => {
4025            format!("readiness timed out after {}", format_millis(*max_wait_ms))
4026        }
4027        EventType::PublishReconciling { method } => {
4028            format!("reconciling publish outcome via {:?}", method)
4029        }
4030        EventType::PublishReconciled { outcome } => {
4031            format!("reconciled publish outcome: {:?}", outcome)
4032        }
4033        other => event_type_name(other).replace('_', " "),
4034    }
4035}
4036
4037fn format_utc(value: chrono::DateTime<chrono::Utc>) -> String {
4038    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
4039}
4040
4041fn format_millis(ms: u64) -> String {
4042    humantime::format_duration(Duration::from_millis(ms)).to_string()
4043}
4044
4045fn run_ci(ci_cmd: CiCommands, state_dir: &Path, workspace_root: &Path) -> Result<()> {
4046    let abs_state = if state_dir.is_absolute() {
4047        state_dir.to_path_buf()
4048    } else {
4049        workspace_root.join(state_dir)
4050    };
4051
4052    match ci_cmd {
4053        CiCommands::GitHubActions => {
4054            println!("# GitHub Actions workflow snippet for Shipper");
4055            println!("# Add these steps to your workflow file");
4056            println!();
4057            println!("# Restore Shipper State (cache for faster restores)");
4058            println!("- name: Restore Shipper State");
4059            println!("  uses: actions/cache@v3");
4060            println!("  with:");
4061            println!("    path: {}/", abs_state.display());
4062            println!("    key: shipper-${{{{ github.sha }}}}");
4063            println!("    restore-keys: |");
4064            println!("      shipper-");
4065            println!();
4066            println!("# Restore Shipper State (artifact for resumability)");
4067            println!("- name: Restore Shipper State Artifact");
4068            println!("  uses: actions/download-artifact@v4");
4069            println!("  with:");
4070            println!("    name: shipper-state");
4071            println!("    path: {}/", abs_state.display());
4072            println!("  continue-on-error: true");
4073            println!();
4074            println!("# Run shipper publish (will resume if state exists)");
4075            println!("- name: Publish Crates");
4076            println!("  run: shipper publish --quiet");
4077            println!("  env:");
4078            println!("    CARGO_REGISTRY_TOKEN: ${{{{ secrets.CARGO_REGISTRY_TOKEN }}}}");
4079            println!();
4080            println!("# Save Shipper State (even if publish fails)");
4081            println!("- name: Save Shipper State");
4082            println!("  if: always()");
4083            println!("  uses: actions/upload-artifact@v3");
4084            println!("  with:");
4085            println!("    name: shipper-state");
4086            println!("    path: {}/", abs_state.display());
4087        }
4088        CiCommands::GitLab => {
4089            println!("# GitLab CI snippet for Shipper");
4090            println!("# Add this to your .gitlab-ci.yml");
4091            println!();
4092            println!("publish:");
4093            println!("  image: rust:latest");
4094            println!("  stage: publish");
4095            println!("  cache:");
4096            println!("    key: ${{CI_COMMIT_REF_SLUG}}");
4097            println!("    paths:");
4098            println!("      - {}/", abs_state.display());
4099            println!("      - target/");
4100            println!("  script:");
4101            println!("    - cargo install shipper --locked");
4102            println!("    - shipper publish --quiet");
4103            println!("  variables:");
4104            println!("    CARGO_TERM_COLOR: \"always\"");
4105            println!("    # Configure this in GitLab CI/CD settings (masked, protected)");
4106            println!("    # CARGO_REGISTRY_TOKEN: \"...\"");
4107            println!("  artifacts:");
4108            println!("    paths:");
4109            println!("      - {}/", abs_state.display());
4110            println!("    expire_in: 1 day");
4111            println!("    when: always");
4112        }
4113        CiCommands::CircleCI => {
4114            println!("# CircleCI config snippet for Shipper");
4115            println!("# Add this to your .circleci/config.yml");
4116            println!();
4117            println!("version: 2.1");
4118            println!();
4119            println!("jobs:");
4120            println!("  publish:");
4121            println!("    docker:");
4122            println!("      - image: cimg/rust:latest");
4123            println!("    steps:");
4124            println!("      - checkout");
4125            println!("      - restore_cache:");
4126            println!("          keys:");
4127            println!("            - shipper-state-{{{{ .Branch }}}}-{{{{ .Revision }}}}");
4128            println!("            - shipper-state-{{{{ .Branch }}}}");
4129            println!("            - shipper-state-");
4130            println!("      - run:");
4131            println!("          name: Install Shipper");
4132            println!("          command: cargo install shipper --locked");
4133            println!("      - run:");
4134            println!("          name: Publish Crates");
4135            println!("          command: shipper publish --quiet");
4136            println!("          environment:");
4137            println!("            CARGO_REGISTRY_TOKEN: ${{{{ CARGO_REGISTRY_TOKEN }}}}");
4138            println!("      - save_cache:");
4139            println!("          key: shipper-state-{{{{ .Branch }}}}-{{{{ .Revision }}}}");
4140            println!("          paths:");
4141            println!("            - {}", abs_state.display());
4142            println!("      - store_artifacts:");
4143            println!("          path: {}", abs_state.display());
4144            println!("          destination: shipper-state");
4145            println!();
4146            println!("workflows:");
4147            println!("  version: 2");
4148            println!("  publish:");
4149            println!("    jobs:");
4150            println!("      - publish:");
4151            println!("          filters:");
4152            println!("            branches:");
4153            println!("              only: main");
4154            println!("          context: cargo-registry");
4155        }
4156        CiCommands::AzureDevOps => {
4157            println!("# Azure DevOps pipeline snippet for Shipper");
4158            println!("# Add this to your azure-pipelines.yml");
4159            println!();
4160            println!("trigger:");
4161            println!("  - main");
4162            println!();
4163            println!("pool:");
4164            println!("  vmImage: 'ubuntu-latest'");
4165            println!();
4166            println!("variables:");
4167            println!("  CARGO_HOME: $(Pipeline.Workspace)/.cargo");
4168            println!();
4169            println!("steps:");
4170            println!("  - task: Cache@2");
4171            println!("    displayName: 'Cache Cargo and Shipper State'");
4172            println!("    inputs:");
4173            println!("      key: 'shipper | \"$(Agent.OS)\" | \"$(Build.SourceVersion)\"'");
4174            println!("      restoreKeys: |");
4175            println!("        shipper | \"$(Agent.OS)\"");
4176            println!("        shipper");
4177            println!("      path: $(CARGO_HOME)");
4178            println!("      cacheHitVar: CACHE_RESTORED");
4179            println!();
4180            println!("  - script: cargo install shipper --locked");
4181            println!("    displayName: 'Install Shipper'");
4182            println!();
4183            println!("  - script: shipper publish --quiet");
4184            println!("    displayName: 'Publish Crates'");
4185            println!("    env:");
4186            println!("      CARGO_REGISTRY_TOKEN: $(CARGO_REGISTRY_TOKEN)");
4187            println!();
4188            println!("  - publish: {}", abs_state.display());
4189            println!("    displayName: 'Publish Shipper State Artifact'");
4190            println!("    condition: succeededOrFailed()");
4191            println!("    artifact: 'shipper-state'");
4192        }
4193    }
4194
4195    Ok(())
4196}
4197
4198fn run_clean(
4199    state_dir: &PathBuf,
4200    workspace_root: &Path,
4201    keep_receipt: bool,
4202    force: bool,
4203) -> Result<()> {
4204    let abs_state = if state_dir.is_absolute() {
4205        state_dir.clone()
4206    } else {
4207        workspace_root.join(state_dir)
4208    };
4209
4210    if !abs_state.exists() {
4211        println!("State directory does not exist: {}", abs_state.display());
4212        return Ok(());
4213    }
4214
4215    // Identify all directories to clean (base + any registry subdirs)
4216    let mut dirs_to_clean = vec![abs_state.clone()];
4217    if let Ok(entries) = std::fs::read_dir(&abs_state) {
4218        for entry in entries.flatten() {
4219            if let Ok(file_type) = entry.file_type()
4220                && file_type.is_dir()
4221                && entry.file_name() != "cache"
4222            {
4223                dirs_to_clean.push(entry.path());
4224            }
4225        }
4226    }
4227
4228    for dir in dirs_to_clean {
4229        clean_single_dir(&dir, workspace_root, keep_receipt, force)?;
4230    }
4231
4232    println!("Clean complete");
4233    Ok(())
4234}
4235
4236fn clean_single_dir(
4237    dir: &Path,
4238    workspace_root: &Path,
4239    keep_receipt: bool,
4240    force: bool,
4241) -> Result<()> {
4242    let state_path = dir.join(shipper_core::state::execution_state::STATE_FILE);
4243    let receipt_path = dir.join(shipper_core::state::execution_state::RECEIPT_FILE);
4244    let reconciliation_path = dir.join(shipper_core::state::execution_state::RECONCILIATION_FILE);
4245    let lock_path = shipper_core::lock::lock_path(dir, Some(workspace_root));
4246
4247    // Check for active lock
4248    if lock_path.exists() {
4249        if force {
4250            eprintln!(
4251                "[warn] --force specified; removing lock file: {}",
4252                lock_path.display()
4253            );
4254            std::fs::remove_file(&lock_path)
4255                .with_context(|| format!("failed to remove lock file {}", lock_path.display()))?;
4256        } else {
4257            match shipper_core::lock::LockFile::read_lock_info(dir, Some(workspace_root)) {
4258                Ok(lock_info) => {
4259                    eprintln!("[warn] Active lock found in {}:", dir.display());
4260                    eprintln!("[warn]   PID: {}", lock_info.pid);
4261                    eprintln!("[warn]   Hostname: {}", lock_info.hostname);
4262                    eprintln!("[warn]   Acquired at: {}", lock_info.acquired_at);
4263                    eprintln!("[warn]   Plan ID: {:?}", lock_info.plan_id);
4264                }
4265                Err(err) => {
4266                    eprintln!(
4267                        "[warn] Active lock found in {} but metadata could not be read: {err:#}",
4268                        dir.display()
4269                    );
4270                }
4271            }
4272            eprintln!("[warn] Use --force to override the lock");
4273            bail!("cannot clean: active lock exists in {}", dir.display());
4274        }
4275    }
4276
4277    // Remove state file
4278    if state_path.exists() {
4279        std::fs::remove_file(&state_path)
4280            .with_context(|| format!("failed to remove state file {}", state_path.display()))?;
4281        println!("Removed: {}", state_path.display());
4282    }
4283
4284    // Remove event logs (authoritative + preflight-only sidecars)
4285    for events_path in discover_event_logs(dir)? {
4286        if events_path.exists() {
4287            std::fs::remove_file(&events_path).with_context(|| {
4288                format!("failed to remove events file {}", events_path.display())
4289            })?;
4290            println!("Removed: {}", events_path.display());
4291        }
4292    }
4293
4294    // Optionally remove receipt file
4295    if !keep_receipt && receipt_path.exists() {
4296        std::fs::remove_file(&receipt_path)
4297            .with_context(|| format!("failed to remove receipt file {}", receipt_path.display()))?;
4298        println!("Removed: {}", receipt_path.display());
4299    } else if keep_receipt && receipt_path.exists() {
4300        println!(
4301            "Kept: {} (--keep-receipt specified)",
4302            receipt_path.display()
4303        );
4304    }
4305
4306    if !keep_receipt && reconciliation_path.exists() {
4307        std::fs::remove_file(&reconciliation_path).with_context(|| {
4308            format!(
4309                "failed to remove reconciliation file {}",
4310                reconciliation_path.display()
4311            )
4312        })?;
4313        println!("Removed: {}", reconciliation_path.display());
4314    } else if keep_receipt && reconciliation_path.exists() {
4315        println!(
4316            "Kept: {} (--keep-receipt specified)",
4317            reconciliation_path.display()
4318        );
4319    }
4320
4321    // Remove cache directory if exists
4322    let cache_dir = dir.join("cache");
4323    if cache_dir.exists() {
4324        std::fs::remove_dir_all(&cache_dir)
4325            .with_context(|| format!("failed to remove cache directory {}", cache_dir.display()))?;
4326        println!("Removed: {}", cache_dir.display());
4327    }
4328
4329    Ok(())
4330}
4331
4332fn run_config(cmd: ConfigCommands) -> Result<()> {
4333    match cmd {
4334        ConfigCommands::Init { output } => {
4335            let template = ShipperConfig::default_toml_template();
4336            std::fs::write(&output, template)
4337                .with_context(|| format!("Failed to write config file to {}", output.display()))?;
4338            println!("Created configuration file: {}", output.display());
4339            println!();
4340            println!("Edit the file to customize shipper settings for your workspace.");
4341            println!("Run `shipper config validate` to check the configuration.");
4342        }
4343        ConfigCommands::Validate { path } => {
4344            if !path.exists() {
4345                bail!("Config file not found: {}", path.display());
4346            }
4347            let config = ShipperConfig::load_from_file(&path)
4348                .with_context(|| format!("Failed to load config file: {}", path.display()))?;
4349            config.validate().with_context(|| {
4350                format!("Configuration validation failed for {}", path.display())
4351            })?;
4352            println!("Configuration file is valid: {}", path.display());
4353        }
4354    }
4355    Ok(())
4356}
4357
4358fn run_completion(shell: &Shell) -> Result<()> {
4359    clap_complete::generate(
4360        *shell,
4361        &mut Cli::command(),
4362        "shipper",
4363        &mut std::io::stdout(),
4364    );
4365    Ok(())
4366}
4367
4368#[cfg(test)]
4369mod tests {
4370    use std::fs;
4371
4372    use chrono::Utc;
4373    use serial_test::serial;
4374    use tempfile::tempdir;
4375
4376    use super::*;
4377
4378    #[derive(Default)]
4379    struct TestReporter {
4380        infos: Vec<String>,
4381        warns: Vec<String>,
4382        errors: Vec<String>,
4383    }
4384
4385    impl Reporter for TestReporter {
4386        fn info(&mut self, msg: &str) {
4387            self.infos.push(msg.to_string());
4388        }
4389
4390        fn warn(&mut self, msg: &str) {
4391            self.warns.push(msg.to_string());
4392        }
4393
4394        fn error(&mut self, msg: &str) {
4395            self.errors.push(msg.to_string());
4396        }
4397    }
4398
4399    #[test]
4400    fn parse_duration_handles_valid_and_invalid_inputs() {
4401        assert!(parse_duration("1s").is_ok());
4402        assert!(parse_duration("nope").is_err());
4403    }
4404
4405    #[test]
4406    fn global_flags_parse_after_subcommand() {
4407        let cli = Cli::try_parse_from([
4408            "shipper",
4409            "preflight",
4410            "--allow-dirty",
4411            "--strict-ownership",
4412            "--verify-mode",
4413            "package",
4414            "--policy",
4415            "safe",
4416            "--format",
4417            "json",
4418        ])
4419        .expect("parse CLI");
4420
4421        assert!(matches!(
4422            cli.cmd,
4423            Some(Commands::Preflight {
4424                preflight_only: false
4425            })
4426        ));
4427        assert!(cli.allow_dirty);
4428        assert!(cli.strict_ownership);
4429        assert_eq!(cli.verify_mode.as_deref(), Some("package"));
4430        assert_eq!(cli.policy.as_deref(), Some("safe"));
4431        assert_eq!(cli.format, "json");
4432    }
4433
4434    // #100 — `--preflight-only` on `shipper preflight` must parse into a
4435    // `fresh_audit=true` signal. This test pins the clap surface: the
4436    // flag is exposed on the preflight subcommand (and defaults to
4437    // `false` on all invocations), and the flag name is scoped to that
4438    // subcommand only — clap must reject it on unrelated subcommands.
4439    #[test]
4440    fn preflight_only_flag_parses_and_defaults_to_false() {
4441        // Explicit: flag present.
4442        let cli = Cli::try_parse_from(["shipper", "preflight", "--preflight-only"])
4443            .expect("parse with flag");
4444        match cli.cmd {
4445            Some(Commands::Preflight { preflight_only }) => assert!(preflight_only),
4446            other => panic!("expected Preflight, got {other:?}"),
4447        }
4448
4449        // Default: flag absent → false.
4450        let cli = Cli::try_parse_from(["shipper", "preflight"]).expect("parse without flag");
4451        match cli.cmd {
4452            Some(Commands::Preflight { preflight_only }) => {
4453                assert!(
4454                    !preflight_only,
4455                    "preflight_only must default to false for back-compat"
4456                );
4457            }
4458            other => panic!("expected Preflight, got {other:?}"),
4459        }
4460
4461        // Flag is scoped to `preflight`: unknown on other subcommands.
4462        Cli::try_parse_from(["shipper", "publish", "--preflight-only"])
4463            .expect_err("must reject --preflight-only on publish");
4464    }
4465
4466    #[test]
4467    fn status_watch_flag_parses() {
4468        let cli = Cli::try_parse_from(["shipper", "status", "--watch"]).expect("parse status");
4469        match cli.cmd {
4470            Some(Commands::Status { watch }) => assert!(watch),
4471            other => panic!("expected Status, got {other:?}"),
4472        }
4473    }
4474
4475    #[test]
4476    fn cli_reporter_methods_are_callable() {
4477        let mut rep = CliReporter::new(false);
4478        rep.info("info");
4479        rep.warn("warn");
4480        rep.error("error");
4481    }
4482
4483    #[test]
4484    fn cli_reporter_retry_wait_without_progress_blocks_for_delay() {
4485        // With no progress handle installed, retry_wait falls back to the
4486        // legacy warn-line + sleep path. Assert it still blocks for the
4487        // full delay (the engine relies on this).
4488        use std::time::Instant;
4489        let mut rep = CliReporter::new(true); // quiet to suppress stderr
4490        let delay = Duration::from_millis(60);
4491        let start = Instant::now();
4492        rep.retry_wait(
4493            "pkg",
4494            "0.1.0",
4495            1,
4496            3,
4497            delay,
4498            shipper_core::types::ErrorClass::Retryable,
4499            "rate limited",
4500        );
4501        assert!(
4502            start.elapsed() >= delay,
4503            "retry_wait returned early: {:?}",
4504            start.elapsed()
4505        );
4506    }
4507
4508    #[test]
4509    fn cli_reporter_retry_wait_without_progress_warns_and_blocks_for_delay() {
4510        use std::time::Instant;
4511        let mut rep = CliReporter::new(false);
4512        let delay = Duration::from_millis(40);
4513        let start = Instant::now();
4514        rep.retry_wait(
4515            "pkg",
4516            "0.1.0",
4517            1,
4518            3,
4519            delay,
4520            shipper_core::types::ErrorClass::Retryable,
4521            "rate limited",
4522        );
4523        assert!(start.elapsed() >= delay);
4524    }
4525
4526    #[test]
4527    fn cli_reporter_retry_wait_with_progress_routes_through_countdown() {
4528        // Installing a (silent) progress handle should route retry_wait
4529        // through ProgressReporter::retry_countdown — still blocks for the
4530        // delay, with no panic from the set_status path.
4531        use std::time::Instant;
4532        let mut rep = CliReporter::new(false);
4533        rep.install_progress(
4534            crate::output::progress::ProgressReporter::silent(2),
4535            BTreeMap::from([(String::from("pkg@1.0.0"), 2usize)]),
4536        );
4537        let delay = Duration::from_millis(40);
4538        let start = Instant::now();
4539        rep.retry_wait(
4540            "pkg",
4541            "1.0.0",
4542            2,
4543            5,
4544            delay,
4545            shipper_core::types::ErrorClass::Retryable,
4546            "server busy",
4547        );
4548        assert!(start.elapsed() >= delay);
4549        assert!(rep.take_progress().is_some());
4550    }
4551
4552    #[test]
4553    fn cli_reporter_retry_wait_updates_progress_to_retrying_package() {
4554        let mut rep = CliReporter::new(true);
4555        rep.install_progress(
4556            crate::output::progress::ProgressReporter::silent(3),
4557            BTreeMap::from([(String::from("beta@0.2.0"), 2usize)]),
4558        );
4559
4560        rep.retry_wait(
4561            "beta",
4562            "0.2.0",
4563            1,
4564            3,
4565            Duration::from_millis(1),
4566            shipper_core::types::ErrorClass::Retryable,
4567            "server busy",
4568        );
4569
4570        let progress = rep.take_progress().expect("progress handle");
4571        assert_eq!(progress.current_package(), 2);
4572        assert_eq!(progress.current_name(), "beta@0.2.0");
4573    }
4574
4575    #[test]
4576    fn cli_reporter_default_impl_preserves_warn_line() {
4577        // Sanity check: TestReporter uses the default retry_wait, which
4578        // should call warn() exactly once with the canonical format.
4579        let mut tr = TestReporter::default();
4580        tr.retry_wait(
4581            "foo",
4582            "1.2.3",
4583            1,
4584            5,
4585            Duration::from_millis(1),
4586            shipper_core::types::ErrorClass::Retryable,
4587            "transient failure",
4588        );
4589        assert_eq!(tr.warns.len(), 1);
4590        let w = &tr.warns[0];
4591        assert!(w.contains("foo@1.2.3"));
4592        assert!(w.contains("transient failure"));
4593        assert!(w.contains("Retryable"));
4594        assert!(w.contains("attempt 2/5"));
4595    }
4596
4597    #[test]
4598    fn preflight_failure_hint_names_common_release_blockers() {
4599        let hint = preflight_failure_hint(Path::new(".shipper"));
4600
4601        for expected in [
4602            "missing token/auth",
4603            "dirty git",
4604            "version already exists",
4605            "ownership failure",
4606            "registry unreachable",
4607        ] {
4608            assert!(hint.contains(expected), "missing `{expected}` in:\n{hint}");
4609        }
4610    }
4611
4612    #[test]
4613    fn publish_failure_hint_names_ambiguity_rate_limit_and_lock_blockers() {
4614        let hint = publish_failure_hint(Path::new(".shipper"));
4615
4616        for expected in [
4617            "ambiguous publish",
4618            "rate limit or Retry-After",
4619            "version already exists",
4620            "stale lock",
4621            "auth/network failure",
4622        ] {
4623            assert!(hint.contains(expected), "missing `{expected}` in:\n{hint}");
4624        }
4625    }
4626
4627    #[test]
4628    fn resume_failure_hint_names_state_and_reconciliation_blockers() {
4629        let hint = resume_failure_hint(Path::new(".shipper"));
4630
4631        for expected in [
4632            "state mismatch",
4633            "corrupt state",
4634            "stale lock",
4635            "ambiguous state",
4636        ] {
4637            assert!(hint.contains(expected), "missing `{expected}` in:\n{hint}");
4638        }
4639    }
4640
4641    #[test]
4642    fn plan_failure_hint_names_manifest_and_package_blockers() {
4643        let hint = plan_failure_hint(
4644            Path::new("missing/Cargo.toml"),
4645            &[String::from("demo")],
4646            "preflight",
4647        );
4648
4649        for expected in [
4650            "missing manifest",
4651            "selected package not publishable",
4652            "Cargo metadata failure",
4653        ] {
4654            assert!(hint.contains(expected), "missing `{expected}` in:\n{hint}");
4655        }
4656    }
4657
4658    #[test]
4659    fn print_cmd_version_reports_missing_command() {
4660        let mut reporter = TestReporter::default();
4661        doctor::print_cmd_version("definitely-not-a-real-command-shipper", &mut reporter);
4662        assert!(reporter.warns.iter().any(|w| w.contains("unable to run")));
4663    }
4664
4665    #[test]
4666    #[serial]
4667    fn print_cmd_version_reports_non_zero_exit() {
4668        let td = tempdir().expect("tempdir");
4669        let bin_dir = td.path().join("bin");
4670        fs::create_dir_all(&bin_dir).expect("mkdir");
4671
4672        #[cfg(windows)]
4673        let cmd_path = {
4674            let p = bin_dir.join("badver.cmd");
4675            fs::write(
4676                &p,
4677                "@echo off\r\necho bad version error 1>&2\r\nexit /b 1\r\n",
4678            )
4679            .expect("write");
4680            p
4681        };
4682
4683        #[cfg(not(windows))]
4684        let cmd_path = {
4685            use std::os::unix::fs::PermissionsExt;
4686
4687            let p = bin_dir.join("badver");
4688            fs::write(
4689                &p,
4690                "#!/usr/bin/env sh\necho bad version error >&2\nexit 1\n",
4691            )
4692            .expect("write");
4693            let mut perms = fs::metadata(&p).expect("meta").permissions();
4694            perms.set_mode(0o755);
4695            fs::set_permissions(&p, perms).expect("chmod");
4696            p
4697        };
4698
4699        let mut reporter = TestReporter::default();
4700        doctor::print_cmd_version(cmd_path.to_str().expect("utf8"), &mut reporter);
4701        assert!(
4702            reporter
4703                .warns
4704                .iter()
4705                .any(|w| w.contains("--version failed"))
4706        );
4707    }
4708
4709    #[test]
4710    fn test_reporter_collects_all_levels() {
4711        let mut reporter = TestReporter::default();
4712        reporter.info("i");
4713        reporter.warn("w");
4714        reporter.error("e");
4715        assert_eq!(reporter.infos, vec!["i".to_string()]);
4716        assert_eq!(reporter.warns, vec!["w".to_string()]);
4717        assert_eq!(reporter.errors, vec!["e".to_string()]);
4718    }
4719
4720    #[test]
4721    fn status_watch_report_summarizes_state_and_scheduled_events() {
4722        let td = tempdir().expect("tempdir");
4723        let state_dir = td.path().join(".shipper");
4724        let now = Utc::now();
4725        let ws = plan::PlannedWorkspace {
4726            workspace_root: td.path().to_path_buf(),
4727            plan: ReleasePlan {
4728                plan_version: "shipper.plan.v1".to_string(),
4729                plan_id: "plan-watch".to_string(),
4730                created_at: now,
4731                registry: Registry::crates_io(),
4732                packages: vec![
4733                    PlannedPackage {
4734                        name: "alpha".to_string(),
4735                        version: "0.1.0".to_string(),
4736                        manifest_path: td.path().join("alpha/Cargo.toml"),
4737                        regime: None,
4738                    },
4739                    PlannedPackage {
4740                        name: "beta".to_string(),
4741                        version: "0.2.0".to_string(),
4742                        manifest_path: td.path().join("beta/Cargo.toml"),
4743                        regime: None,
4744                    },
4745                ],
4746                dependencies: BTreeMap::new(),
4747            },
4748            skipped: vec![],
4749        };
4750
4751        let state = ExecutionState {
4752            state_version: "shipper.state.v1".to_string(),
4753            plan_id: "plan-watch".to_string(),
4754            registry: Registry::crates_io(),
4755            created_at: now,
4756            updated_at: now,
4757            attempt_history: Vec::new(),
4758            packages: BTreeMap::from([
4759                (
4760                    "alpha@0.1.0".to_string(),
4761                    shipper_core::types::PackageProgress {
4762                        name: "alpha".to_string(),
4763                        version: "0.1.0".to_string(),
4764                        attempts: 1,
4765                        state: PackageState::Published,
4766                        last_updated_at: now,
4767                    },
4768                ),
4769                (
4770                    "beta@0.2.0".to_string(),
4771                    shipper_core::types::PackageProgress {
4772                        name: "beta".to_string(),
4773                        version: "0.2.0".to_string(),
4774                        attempts: 1,
4775                        state: PackageState::Uploaded,
4776                        last_updated_at: now,
4777                    },
4778                ),
4779            ]),
4780        };
4781        shipper_core::state::execution_state::save_state(&state_dir, &state).expect("save state");
4782
4783        let next_poll_at = now + chrono::Duration::seconds(5);
4784        let mut event_log = shipper_core::state::events::EventLog::new();
4785        event_log.record(PublishEvent {
4786            timestamp: now,
4787            package: "beta@0.2.0".to_string(),
4788            event_type: EventType::ReadinessPollScheduled {
4789                attempt: 1,
4790                delay_ms: 5_000,
4791                next_poll_at,
4792            },
4793        });
4794        event_log
4795            .write_to_file(&shipper_core::state::events::events_path(&state_dir))
4796            .expect("write events");
4797
4798        let report = build_status_watch_report(&ws, &state_dir).expect("report");
4799        assert_eq!(report.schema_version, "shipper.status.watch.v1");
4800        assert_eq!(report.counts.published, 1);
4801        assert_eq!(report.counts.uploaded, 1);
4802        assert_eq!(report.current_package.as_deref(), Some("beta@0.2.0"));
4803        assert_eq!(
4804            report.next_action.as_ref().map(|action| action.kind),
4805            Some("readiness_poll")
4806        );
4807
4808        let mut rendered = Vec::new();
4809        write_status_watch_report(&report, "text", &mut rendered).expect("render");
4810        let rendered = String::from_utf8(rendered).expect("utf8");
4811        assert!(rendered.contains("Status watch"));
4812        assert!(rendered.contains("progress: published=1 pending=0 uploaded=1"));
4813        assert!(rendered.contains("next: readiness_poll beta@0.2.0"));
4814
4815        let mut rendered_json = Vec::new();
4816        write_status_watch_report(&report, "json", &mut rendered_json).expect("render JSON");
4817        let rendered_json = String::from_utf8(rendered_json).expect("utf8");
4818        let json: serde_json::Value =
4819            serde_json::from_str(&rendered_json).expect("status watch JSON");
4820        assert_eq!(
4821            json.pointer("/schema_version")
4822                .and_then(serde_json::Value::as_str),
4823            Some("shipper.status.watch.v1")
4824        );
4825    }
4826
4827    #[test]
4828    fn status_watch_next_action_ignores_stale_schedules_after_terminal_event() {
4829        let now = Utc::now();
4830        let scheduled = PublishEvent {
4831            timestamp: now,
4832            package: "beta@0.2.0".to_string(),
4833            event_type: EventType::RetryScheduled {
4834                attempt: 1,
4835                max_attempts: 3,
4836                delay_ms: 5_000,
4837                next_attempt_at: now + chrono::Duration::seconds(5),
4838                reason: shipper_core::types::ErrorClass::Retryable,
4839                message: "rate limited".to_string(),
4840            },
4841        };
4842        assert!(latest_status_watch_next_action(std::slice::from_ref(&scheduled)).is_some());
4843
4844        let published = PublishEvent {
4845            timestamp: now,
4846            package: "beta@0.2.0".to_string(),
4847            event_type: EventType::PackagePublished { duration_ms: 10 },
4848        };
4849        let events = vec![scheduled, published];
4850        assert!(latest_status_watch_next_action(&events).is_none());
4851    }
4852
4853    #[test]
4854    fn status_watch_current_package_ignores_stale_active_events_after_terminal_event() {
4855        let now = Utc::now();
4856        let events = vec![
4857            PublishEvent {
4858                timestamp: now,
4859                package: "beta@0.2.0".to_string(),
4860                event_type: EventType::PackageStarted {
4861                    name: "beta".to_string(),
4862                    version: "0.2.0".to_string(),
4863                },
4864            },
4865            PublishEvent {
4866                timestamp: now,
4867                package: "beta@0.2.0".to_string(),
4868                event_type: EventType::PackagePublished { duration_ms: 10 },
4869            },
4870        ];
4871        let packages = vec![StatusWatchPackageReport {
4872            name: "beta".to_string(),
4873            version: "0.2.0".to_string(),
4874            state: "published".to_string(),
4875            attempts: 1,
4876            last_updated_at: Some(format_utc(now)),
4877        }];
4878        assert_eq!(current_status_package(&events, None, &packages), None);
4879    }
4880
4881    #[test]
4882    fn status_watch_event_reader_ignores_incomplete_tail_line() {
4883        let td = tempdir().expect("tempdir");
4884        let events_path = td.path().join("events.jsonl");
4885        let event = PublishEvent {
4886            timestamp: Utc::now(),
4887            package: "beta@0.2.0".to_string(),
4888            event_type: EventType::PackageStarted {
4889                name: "beta".to_string(),
4890                version: "0.2.0".to_string(),
4891            },
4892        };
4893        let mut content = serde_json::to_string(&event).expect("serialize event");
4894        content.push('\n');
4895        content.push_str("{\"type\":\"package_started\"");
4896        fs::write(&events_path, content).expect("write events");
4897
4898        let events = read_status_watch_events(&events_path).expect("read events");
4899        assert_eq!(events.len(), 1);
4900    }
4901
4902    #[test]
4903    #[serial]
4904    fn run_doctor_supports_absolute_state_dir() {
4905        let td = tempdir().expect("tempdir");
4906        let ws = plan::PlannedWorkspace {
4907            workspace_root: td.path().to_path_buf(),
4908            plan: shipper_core::types::ReleasePlan {
4909                plan_version: "1".to_string(),
4910                plan_id: "plan-x".to_string(),
4911                created_at: chrono::Utc::now(),
4912                registry: Registry::crates_io(),
4913                packages: vec![],
4914                dependencies: std::collections::BTreeMap::new(),
4915            },
4916            skipped: vec![],
4917        };
4918
4919        let state_dir = td.path().join("abs-state");
4920        let opts = RuntimeOptions {
4921            allow_dirty: true,
4922            skip_ownership_check: true,
4923            strict_ownership: false,
4924            no_verify: false,
4925            max_attempts: 1,
4926            base_delay: Duration::from_millis(0),
4927            max_delay: Duration::from_millis(0),
4928            retry_strategy: shipper_core::retry::RetryStrategyType::Exponential,
4929            retry_jitter: 0.5,
4930            retry_per_error: shipper_core::retry::PerErrorConfig::default(),
4931            verify_timeout: Duration::from_millis(0),
4932            verify_poll_interval: Duration::from_millis(0),
4933            state_dir: state_dir.clone(),
4934            force_resume: false,
4935            force: false,
4936            lock_timeout: Duration::from_secs(3600),
4937            policy: shipper_core::types::PublishPolicy::Safe,
4938            verify_mode: shipper_core::types::VerifyMode::Workspace,
4939            readiness: shipper_core::types::ReadinessConfig::default(),
4940            output_lines: 50,
4941            parallel: shipper_core::types::ParallelConfig::default(),
4942            webhook: shipper_core::webhook::WebhookConfig::default(),
4943            encryption: shipper_core::encryption::EncryptionConfig::default(),
4944            registries: vec![],
4945            resume_from: None,
4946            rehearsal_registry: None,
4947            rehearsal_skip: false,
4948            rehearsal_smoke_install: None,
4949        };
4950
4951        fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
4952
4953        temp_env::with_vars(
4954            [
4955                ("CARGO_REGISTRY_TOKEN", None::<String>),
4956                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
4957                (
4958                    "CARGO_HOME",
4959                    Some(
4960                        td.path()
4961                            .join("cargo-home")
4962                            .to_str()
4963                            .expect("utf8")
4964                            .to_string(),
4965                    ),
4966                ),
4967            ],
4968            || {
4969                let mut reporter = TestReporter::default();
4970                doctor::run(&ws, &opts, &mut reporter).expect("doctor");
4971            },
4972        );
4973    }
4974
4975    #[test]
4976    #[serial]
4977    fn run_doctor_restores_env_when_old_values_are_missing_or_present() {
4978        let td = tempdir().expect("tempdir");
4979        let ws = plan::PlannedWorkspace {
4980            workspace_root: td.path().to_path_buf(),
4981            plan: shipper_core::types::ReleasePlan {
4982                plan_version: "1".to_string(),
4983                plan_id: "plan-y".to_string(),
4984                created_at: chrono::Utc::now(),
4985                registry: Registry::crates_io(),
4986                packages: vec![],
4987                dependencies: std::collections::BTreeMap::new(),
4988            },
4989            skipped: vec![],
4990        };
4991
4992        let opts = RuntimeOptions {
4993            allow_dirty: true,
4994            skip_ownership_check: true,
4995            strict_ownership: false,
4996            no_verify: false,
4997            max_attempts: 1,
4998            base_delay: Duration::from_millis(0),
4999            max_delay: Duration::from_millis(0),
5000            retry_strategy: shipper_core::retry::RetryStrategyType::Exponential,
5001            retry_jitter: 0.5,
5002            retry_per_error: shipper_core::retry::PerErrorConfig::default(),
5003            verify_timeout: Duration::from_millis(0),
5004            verify_poll_interval: Duration::from_millis(0),
5005            state_dir: td.path().join("abs-state-2"),
5006            force_resume: false,
5007            force: false,
5008            lock_timeout: Duration::from_secs(3600),
5009            policy: shipper_core::types::PublishPolicy::Safe,
5010            verify_mode: shipper_core::types::VerifyMode::Workspace,
5011            readiness: shipper_core::types::ReadinessConfig::default(),
5012            output_lines: 50,
5013            parallel: shipper_core::types::ParallelConfig::default(),
5014            webhook: shipper_core::webhook::WebhookConfig::default(),
5015            encryption: shipper_core::encryption::EncryptionConfig::default(),
5016            registries: vec![],
5017            resume_from: None,
5018            rehearsal_registry: None,
5019            rehearsal_skip: false,
5020            rehearsal_smoke_install: None,
5021        };
5022
5023        fs::create_dir_all(td.path().join("cargo-home")).expect("mkdir");
5024
5025        temp_env::with_vars(
5026            [
5027                ("CARGO_REGISTRY_TOKEN", None::<String>),
5028                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
5029                (
5030                    "CARGO_HOME",
5031                    Some(
5032                        td.path()
5033                            .join("cargo-home")
5034                            .to_str()
5035                            .expect("utf8")
5036                            .to_string(),
5037                    ),
5038                ),
5039            ],
5040            || {
5041                let mut reporter = TestReporter::default();
5042                doctor::run(&ws, &opts, &mut reporter).expect("doctor");
5043            },
5044        );
5045    }
5046
5047    #[test]
5048    fn config_init_creates_file() {
5049        let td = tempdir().expect("tempdir");
5050        let config_path = td.path().join("test-config.toml");
5051
5052        run_config(ConfigCommands::Init {
5053            output: config_path.clone(),
5054        })
5055        .expect("config init should succeed");
5056
5057        assert!(config_path.exists(), "config file should be created");
5058
5059        let content = fs::read_to_string(&config_path).expect("read config file");
5060        assert!(
5061            content.contains("[policy]"),
5062            "config should contain [policy] section"
5063        );
5064        assert!(
5065            content.contains("[readiness]"),
5066            "config should contain [readiness] section"
5067        );
5068    }
5069
5070    #[test]
5071    fn config_validate_valid_file() {
5072        let td = tempdir().expect("tempdir");
5073        let config_path = td.path().join("test-config.toml");
5074
5075        // Create a valid config
5076        let valid_config = r#"
5077[policy]
5078mode = "safe"
5079
5080[verify]
5081mode = "workspace"
5082
5083[readiness]
5084enabled = true
5085method = "api"
5086initial_delay = "1s"
5087max_delay = "60s"
5088max_total_wait = "5m"
5089poll_interval = "2s"
5090jitter_factor = 0.5
5091
5092[output]
5093lines = 50
5094
5095[retry]
5096max_attempts = 6
5097base_delay = "2s"
5098max_delay = "2m"
5099
5100[lock]
5101timeout = "1h"
5102"#;
5103
5104        fs::write(&config_path, valid_config).expect("write config file");
5105
5106        run_config(ConfigCommands::Validate {
5107            path: config_path.clone(),
5108        })
5109        .expect("config validate should succeed for valid file");
5110    }
5111
5112    #[test]
5113    fn config_validate_invalid_file() {
5114        let td = tempdir().expect("tempdir");
5115        let config_path = td.path().join("test-config.toml");
5116
5117        // Create an invalid config (output_lines = 0)
5118        let invalid_config = r#"
5119[output]
5120lines = 0
5121"#;
5122
5123        fs::write(&config_path, invalid_config).expect("write config file");
5124
5125        let result = run_config(ConfigCommands::Validate {
5126            path: config_path.clone(),
5127        });
5128
5129        assert!(
5130            result.is_err(),
5131            "config validate should fail for invalid file"
5132        );
5133        let err = result.unwrap_err().to_string();
5134        // The error is wrapped in context, so check the full message
5135        assert!(
5136            err.contains("output.lines must be greater than 0")
5137                || err.contains("Configuration validation failed"),
5138            "error should mention output.lines or validation failed"
5139        );
5140    }
5141
5142    #[test]
5143    fn config_validate_missing_file() {
5144        let td = tempdir().expect("tempdir");
5145        let config_path = td.path().join("nonexistent-config.toml");
5146
5147        let result = run_config(ConfigCommands::Validate {
5148            path: config_path.clone(),
5149        });
5150
5151        assert!(
5152            result.is_err(),
5153            "config validate should fail for missing file"
5154        );
5155        let err = result.unwrap_err().to_string();
5156        assert!(
5157            err.contains("not found") || err.contains("Config file not found"),
5158            "error should mention file not found"
5159        );
5160    }
5161
5162    #[test]
5163    fn config_load_from_workspace() {
5164        let td = tempdir().expect("tempdir");
5165        let workspace_root = td.path();
5166
5167        // No config file exists
5168        let result = ShipperConfig::load_from_workspace(workspace_root);
5169        assert!(
5170            result.is_ok(),
5171            "load should succeed even without config file"
5172        );
5173        assert!(
5174            result.unwrap().is_none(),
5175            "should return None when no config exists"
5176        );
5177
5178        // Create a config file
5179        let config_path = workspace_root.join(".shipper.toml");
5180        let valid_config = r#"
5181[policy]
5182mode = "fast"
5183"#;
5184
5185        fs::write(&config_path, valid_config).expect("write config file");
5186
5187        let result = ShipperConfig::load_from_workspace(workspace_root);
5188        assert!(result.is_ok(), "load should succeed");
5189        let config = result.unwrap();
5190        assert!(config.is_some(), "should return Some when config exists");
5191        assert_eq!(
5192            config.unwrap().policy.mode,
5193            shipper_core::config::PublishPolicy::Fast
5194        );
5195    }
5196
5197    #[test]
5198    fn config_merge_with_cli_overrides() {
5199        let config = ShipperConfig {
5200            schema_version: "shipper.config.v1".to_string(),
5201            policy: shipper_core::config::PolicyConfig {
5202                mode: shipper_core::config::PublishPolicy::Safe,
5203            },
5204            verify: shipper_core::config::VerifyConfig {
5205                mode: shipper_core::config::VerifyMode::Workspace,
5206            },
5207            readiness: shipper_core::config::ReadinessConfig::default(),
5208            output: shipper_core::config::OutputConfig { lines: 100 },
5209            lock: shipper_core::config::LockConfig {
5210                timeout: Duration::from_secs(1800),
5211            },
5212            flags: shipper_core::config::FlagsConfig {
5213                allow_dirty: false,
5214                skip_ownership_check: false,
5215                strict_ownership: false,
5216            },
5217            retry: shipper_core::config::RetryConfig {
5218                policy: shipper_core::retry::RetryPolicy::Custom,
5219                max_attempts: 10,
5220                base_delay: Duration::from_secs(5),
5221                max_delay: Duration::from_secs(300),
5222                strategy: shipper_core::retry::RetryStrategyType::Exponential,
5223                jitter: 0.5,
5224                per_error: shipper_core::retry::PerErrorConfig::default(),
5225            },
5226            state_dir: None,
5227            registry: None,
5228            registries: shipper_core::config::MultiRegistryConfig::default(),
5229            parallel: shipper_core::config::ParallelConfig::default(),
5230            webhook: shipper_core::config::WebhookConfig::default(),
5231            encryption: shipper_core::config::EncryptionConfigInner::default(),
5232            storage: shipper_core::config::StorageConfigInner::default(),
5233            rehearsal: shipper_core::config::RehearsalConfig::default(),
5234        };
5235
5236        // CLI overrides some values, leaves others as None
5237        let cli = CliOverrides {
5238            allow_dirty: true,
5239            max_attempts: Some(3),
5240            output_lines: Some(50),
5241            policy: Some(shipper_core::config::PublishPolicy::Fast),
5242            verify_mode: Some(shipper_core::config::VerifyMode::None),
5243            ..Default::default()
5244        };
5245
5246        let merged: RuntimeOptions = config.build_runtime_options(cli);
5247
5248        // CLI values should win where set
5249        assert!(merged.allow_dirty, "CLI allow_dirty should win");
5250        assert_eq!(merged.max_attempts, 3, "CLI max_attempts should win");
5251        assert_eq!(merged.output_lines, 50, "CLI output_lines should win");
5252        assert_eq!(
5253            merged.policy,
5254            shipper_core::types::PublishPolicy::Fast,
5255            "CLI policy should win"
5256        );
5257        assert_eq!(
5258            merged.verify_mode,
5259            shipper_core::types::VerifyMode::None,
5260            "CLI verify_mode should win"
5261        );
5262
5263        // Config values should apply where CLI is None
5264        assert_eq!(
5265            merged.base_delay,
5266            Duration::from_secs(5),
5267            "config base_delay should apply"
5268        );
5269        assert_eq!(
5270            merged.max_delay,
5271            Duration::from_secs(300),
5272            "config max_delay should apply"
5273        );
5274        assert_eq!(
5275            merged.lock_timeout,
5276            Duration::from_secs(1800),
5277            "config lock_timeout should apply"
5278        );
5279    }
5280
5281    #[test]
5282    fn run_clean_errors_when_lock_exists_without_force() {
5283        let td = tempdir().expect("tempdir");
5284        let state_dir = PathBuf::from(".shipper");
5285        let abs_state = td.path().join(&state_dir);
5286        fs::create_dir_all(&abs_state).expect("mkdir");
5287
5288        let lock_info = shipper_core::lock::LockInfo {
5289            pid: 12345,
5290            hostname: "test-host".to_string(),
5291            acquired_at: Utc::now(),
5292            plan_id: Some("plan-123".to_string()),
5293        };
5294        let lock_path = shipper_core::lock::lock_path(&abs_state, Some(td.path()));
5295        fs::write(
5296            &lock_path,
5297            serde_json::to_string(&lock_info).expect("serialize"),
5298        )
5299        .expect("write lock");
5300
5301        let err = run_clean(&state_dir, td.path(), false, false).expect_err("must fail");
5302        assert!(err.to_string().contains("cannot clean: active lock exists"));
5303        assert!(lock_path.exists());
5304    }
5305
5306    #[test]
5307    fn run_clean_force_removes_lock_and_state_files() {
5308        let td = tempdir().expect("tempdir");
5309        let state_dir = PathBuf::from(".shipper");
5310        let abs_state = td.path().join(&state_dir);
5311        fs::create_dir_all(&abs_state).expect("mkdir");
5312
5313        let state_path = abs_state.join(shipper_core::state::execution_state::STATE_FILE);
5314        let receipt_path = abs_state.join(shipper_core::state::execution_state::RECEIPT_FILE);
5315        let reconciliation_path =
5316            abs_state.join(shipper_core::state::execution_state::RECONCILIATION_FILE);
5317        let events_path = abs_state.join(shipper_core::state::events::EVENTS_FILE);
5318        let preflight_only_events_path =
5319            abs_state.join("preflight-only-20260421T010101000000000Z-pid123.events.jsonl");
5320        let lock_path = shipper_core::lock::lock_path(&abs_state, Some(td.path()));
5321
5322        fs::write(&state_path, "{}").expect("write state");
5323        fs::write(&receipt_path, "{}").expect("write receipt");
5324        fs::write(&reconciliation_path, "{}").expect("write reconciliation");
5325        fs::write(&events_path, "{}").expect("write events");
5326        fs::write(&preflight_only_events_path, "{}").expect("write preflight-only events");
5327
5328        let lock_info = shipper_core::lock::LockInfo {
5329            pid: 12345,
5330            hostname: "test-host".to_string(),
5331            acquired_at: Utc::now(),
5332            plan_id: Some("plan-123".to_string()),
5333        };
5334        fs::write(
5335            &lock_path,
5336            serde_json::to_string(&lock_info).expect("serialize"),
5337        )
5338        .expect("write lock");
5339
5340        run_clean(&state_dir, td.path(), false, true).expect("clean with force");
5341
5342        assert!(!state_path.exists(), "state file should be removed");
5343        assert!(!receipt_path.exists(), "receipt file should be removed");
5344        assert!(
5345            !reconciliation_path.exists(),
5346            "reconciliation file should be removed"
5347        );
5348        assert!(!events_path.exists(), "events file should be removed");
5349        assert!(
5350            !preflight_only_events_path.exists(),
5351            "preflight-only sidecar should be removed"
5352        );
5353        assert!(!lock_path.exists(), "lock file should be removed");
5354    }
5355
5356    #[test]
5357    fn write_event_lines_since_streams_only_new_events() {
5358        let td = tempdir().expect("tempdir");
5359        let events_path = td.path().join("events.jsonl");
5360        fs::write(
5361            &events_path,
5362            concat!(
5363                r#"{"timestamp":"2025-01-01T00:00:00Z","event_type":{"type":"plan_created","plan_id":"abc123","package_count":1},"package":"all"}"#,
5364                "\n",
5365            ),
5366        )
5367        .expect("write first event");
5368
5369        let mut out = Vec::new();
5370        let offset =
5371            write_event_lines_since(&events_path, 0, "json", &mut out).expect("read first");
5372        let first = String::from_utf8(out).expect("utf8");
5373        assert!(first.contains(r#""type":"plan_created""#));
5374        assert_eq!(first.lines().count(), 1);
5375
5376        fs::OpenOptions::new()
5377            .append(true)
5378            .open(&events_path)
5379            .expect("open append")
5380            .write_all(
5381                concat!(
5382                    r#"{"timestamp":"2025-01-01T00:00:01Z","event_type":{"type":"execution_started"},"package":"all"}"#,
5383                    "\n",
5384                )
5385                .as_bytes(),
5386            )
5387            .expect("append event");
5388
5389        let mut out = Vec::new();
5390        let next_offset =
5391            write_event_lines_since(&events_path, offset, "json", &mut out).expect("read second");
5392        let second = String::from_utf8(out).expect("utf8");
5393        assert!(second.contains(r#""type":"execution_started""#));
5394        assert!(!second.contains(r#""type":"plan_created""#));
5395        assert_eq!(second.lines().count(), 1);
5396        assert!(next_offset > offset);
5397    }
5398
5399    #[test]
5400    fn inspect_events_follow_defers_incomplete_tail_line() {
5401        let td = tempdir().expect("tempdir");
5402        let events_path = td.path().join("events.jsonl");
5403        let first_event = concat!(
5404            r#"{"timestamp":"2025-01-01T00:00:00Z","event_type":{"type":"plan_created","plan_id":"abc123","package_count":1},"package":"all"}"#,
5405            "\n",
5406        );
5407        let partial_event =
5408            r#"{"timestamp":"2025-01-01T00:00:01Z","event_type":{"type":"execution_started"}"#;
5409        fs::write(&events_path, format!("{first_event}{partial_event}")).expect("write events");
5410
5411        let mut out = Vec::new();
5412        let offset =
5413            write_event_lines_since(&events_path, 0, "json", &mut out).expect("read complete");
5414        let text = String::from_utf8(out).expect("utf8");
5415
5416        assert_eq!(offset, first_event.len() as u64);
5417        assert!(text.contains(r#""type":"plan_created""#));
5418        assert!(!text.contains(r#""type":"execution_started""#));
5419        assert_eq!(text.lines().count(), 1);
5420
5421        fs::OpenOptions::new()
5422            .append(true)
5423            .open(&events_path)
5424            .expect("open append")
5425            .write_all(br#","package":"all"}"#)
5426            .expect("append event body");
5427        fs::OpenOptions::new()
5428            .append(true)
5429            .open(&events_path)
5430            .expect("open append")
5431            .write_all(b"\n")
5432            .expect("append newline");
5433
5434        let mut out = Vec::new();
5435        let next_offset =
5436            write_event_lines_since(&events_path, offset, "json", &mut out).expect("read tail");
5437        let text = String::from_utf8(out).expect("utf8");
5438
5439        assert!(next_offset > offset);
5440        assert!(text.contains(r#""type":"execution_started""#));
5441        assert!(!text.contains(r#""type":"plan_created""#));
5442        assert_eq!(text.lines().count(), 1);
5443    }
5444
5445    #[test]
5446    fn inspect_events_follow_reports_completed_malformed_line() {
5447        let td = tempdir().expect("tempdir");
5448        let events_path = td.path().join("events.jsonl");
5449        fs::write(&events_path, "{\"not\":\"a publish event\"}\n").expect("write events");
5450
5451        let mut out = Vec::new();
5452        let err = write_event_lines_since(&events_path, 0, "json", &mut out)
5453            .expect_err("malformed completed line should fail");
5454
5455        assert!(
5456            err.to_string().contains("failed to parse event JSON"),
5457            "{err:#}"
5458        );
5459        assert!(out.is_empty());
5460    }
5461
5462    #[test]
5463    fn write_event_lines_since_missing_file_keeps_offset() {
5464        let td = tempdir().expect("tempdir");
5465        let events_path = td.path().join("missing-events.jsonl");
5466        let mut out = Vec::new();
5467
5468        let offset =
5469            write_event_lines_since(&events_path, 42, "text", &mut out).expect("missing file");
5470
5471        assert_eq!(offset, 42);
5472        assert!(out.is_empty());
5473    }
5474
5475    #[test]
5476    fn write_event_lines_since_renders_text_follow_events() {
5477        let td = tempdir().expect("tempdir");
5478        let events_path = td.path().join("events.jsonl");
5479        fs::write(
5480            &events_path,
5481            concat!(
5482                r#"{"timestamp":"2025-01-01T00:00:00Z","event_type":{"type":"plan_created","plan_id":"abc123","package_count":1},"package":"all"}"#,
5483                "\n",
5484                r#"{"timestamp":"2025-01-01T00:00:01Z","event_type":{"type":"execution_started"},"package":"all"}"#,
5485                "\n",
5486            ),
5487        )
5488        .expect("write events");
5489
5490        let mut out = Vec::new();
5491        let offset = write_event_lines_since(&events_path, 0, "text", &mut out).expect("read");
5492        let text = String::from_utf8(out).expect("utf8");
5493
5494        assert!(offset > 0);
5495        assert!(text.contains("2025-01-01T00:00:00Z all plan_created - plan created"));
5496        assert!(text.contains("2025-01-01T00:00:01Z all execution_started - execution started"));
5497        assert!(!text.contains(r#""type":"plan_created""#));
5498    }
5499
5500    #[test]
5501    fn discover_event_logs_includes_preflight_only_sidecars() {
5502        let td = tempdir().expect("tempdir");
5503        let state_dir = td.path().join(".shipper");
5504        fs::create_dir_all(&state_dir).expect("mkdir");
5505
5506        let sidecar = state_dir.join("preflight-only-20260421T010101000000000Z-pid1.events.jsonl");
5507        fs::write(&sidecar, "{}").expect("write sidecar");
5508
5509        let discovered = discover_event_logs(&state_dir).expect("discover event logs");
5510        assert_eq!(discovered, vec![sidecar]);
5511    }
5512}