1use 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
53const 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 #[arg(short = 'V', long = "version", global = true)]
78 version: bool,
79
80 #[arg(long, global = true)]
82 config: Option<PathBuf>,
83
84 #[arg(long, default_value = "Cargo.toml", global = true)]
86 manifest_path: PathBuf,
87
88 #[arg(long, global = true)]
90 registry: Option<String>,
91
92 #[arg(long, global = true)]
94 api_base: Option<String>,
95
96 #[arg(long = "package", global = true)]
98 packages: Vec<String>,
99
100 #[arg(long, global = true)]
102 state_dir: Option<PathBuf>,
103
104 #[arg(long, global = true)]
106 output_lines: Option<usize>,
107
108 #[arg(long, global = true)]
110 allow_dirty: bool,
111
112 #[arg(long, global = true)]
114 skip_ownership_check: bool,
115
116 #[arg(long, global = true)]
120 strict_ownership: bool,
121
122 #[arg(long, global = true)]
124 no_verify: bool,
125
126 #[arg(long, global = true)]
128 max_attempts: Option<u32>,
129
130 #[arg(long, global = true)]
132 base_delay: Option<String>,
133
134 #[arg(long, global = true)]
136 max_delay: Option<String>,
137
138 #[arg(long, global = true)]
140 retry_strategy: Option<String>,
141
142 #[arg(long, global = true)]
144 retry_jitter: Option<f64>,
145
146 #[arg(long, global = true)]
148 verify_timeout: Option<String>,
149
150 #[arg(long, global = true)]
152 verify_poll: Option<String>,
153
154 #[arg(long, global = true)]
156 readiness_method: Option<String>,
157
158 #[arg(long, global = true)]
160 readiness_timeout: Option<String>,
161
162 #[arg(long, global = true)]
164 readiness_poll: Option<String>,
165
166 #[arg(long, global = true)]
168 no_readiness: bool,
169
170 #[arg(long, global = true)]
172 force_resume: bool,
173
174 #[arg(long, global = true)]
176 force: bool,
177
178 #[arg(long, global = true)]
180 lock_timeout: Option<String>,
181
182 #[arg(long, global = true)]
184 policy: Option<String>,
185
186 #[arg(long, global = true)]
188 verify_mode: Option<String>,
189
190 #[arg(long, global = true)]
192 parallel: bool,
193
194 #[arg(long, global = true)]
196 max_concurrent: Option<usize>,
197
198 #[arg(long, global = true)]
200 per_package_timeout: Option<String>,
201
202 #[arg(long, global = true)]
204 webhook_url: Option<String>,
205
206 #[arg(long, global = true)]
208 webhook_secret: Option<String>,
209
210 #[arg(long, global = true)]
212 encrypt: bool,
213
214 #[arg(long, global = true)]
216 encrypt_passphrase: Option<String>,
217
218 #[arg(long, global = true)]
221 registries: Option<String>,
222
223 #[arg(long, global = true)]
225 all_registries: bool,
226
227 #[arg(long, global = true)]
229 resume_from: Option<String>,
230
231 #[arg(long, global = true)]
238 rehearsal_registry: Option<String>,
239
240 #[arg(long, global = true)]
246 skip_rehearsal: bool,
247
248 #[arg(long = "smoke-install", global = true, value_name = "CRATE")]
259 rehearsal_smoke_install: Option<String>,
260
261 #[arg(long, default_value = "text", value_parser = ["text", "json"], global = true)]
263 format: String,
264
265 #[arg(long, global = true)]
267 verbose: bool,
268
269 #[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 #[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 #[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 #[arg(long = "preflight-only")]
325 preflight_only: bool,
326 },
327 #[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 #[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,
382 #[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 #[arg(long)]
407 watch: bool,
408 },
409 #[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 #[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 #[arg(long)]
443 follow: bool,
444 },
445 #[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 #[command(subcommand)]
462 Ci(CiCommands),
463 Clean {
465 #[arg(long)]
467 keep_receipt: bool,
468 },
469 Yank {
481 #[arg(long = "crate", value_name = "NAME", conflicts_with = "plan")]
484 crate_name: Option<String>,
485 #[arg(long, value_name = "VERSION", conflicts_with = "plan")]
488 version: Option<String>,
489 #[arg(long, conflicts_with = "plan")]
496 reason: Option<String>,
497 #[arg(long)]
501 mark_compromised: bool,
502 #[arg(long, value_name = "PATH")]
508 plan: Option<PathBuf>,
509 },
510 PlanYank {
522 #[arg(long, value_name = "PATH")]
525 from_receipt: Option<PathBuf>,
526 #[arg(long, conflicts_with = "starting_crate")]
531 compromised_only: bool,
532 #[arg(long, value_name = "CRATE")]
540 starting_crate: Option<String>,
541 #[arg(long, value_name = "REASON")]
545 reason: Option<String>,
546 },
547 #[command(name = "fix-forward")]
561 FixForward {
562 #[arg(long, value_name = "PATH")]
565 from_receipt: Option<PathBuf>,
566 },
567 #[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 #[arg(long, value_name = "PATH", conflicts_with = "execute_plan")]
600 from_receipt: Option<PathBuf>,
601 #[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 #[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 #[arg(
619 long,
620 value_name = "REASON",
621 required_unless_present = "execute_plan",
622 conflicts_with = "execute_plan"
623 )]
624 reason: Option<String>,
625 #[arg(long, conflicts_with = "execute_plan")]
628 dry_run: bool,
629 #[arg(long = "execute-plan", value_name = "PATH")]
632 execute_plan: Option<PathBuf>,
633 },
634 #[command(subcommand)]
636 Config(ConfigCommands),
637 Completion {
639 #[arg(value_enum)]
641 shell: Shell,
642 },
643}
644
645#[derive(Subcommand, Debug)]
646enum CiCommands {
647 #[command(name = "github-actions")]
649 GitHubActions,
650 #[command(name = "gitlab")]
652 GitLab,
653 #[command(name = "circleci")]
655 CircleCI,
656 #[command(name = "azure-devops")]
658 AzureDevOps,
659}
660
661#[derive(Subcommand, Debug, Clone)]
662enum ConfigCommands {
663 Init {
665 #[arg(short, long, default_value = ".shipper.toml")]
667 output: PathBuf,
668 },
669 Validate {
671 #[arg(short, long, default_value = ".shipper.toml")]
673 path: PathBuf,
674 },
675}
676
677struct CliReporter {
678 quiet: bool,
679 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 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
775pub fn run() -> Result<()> {
780 let cli = Cli::parse();
781
782 if cli.version {
783 print_version(cli.verbose);
784 return Ok(());
785 }
786
787 if let Some(Commands::Config(config_cmd)) = &cli.cmd {
789 return run_config(config_cmd.clone());
790 }
791
792 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 let config =
839 if let Some(ref config_path) = cli.config {
840 Some(ShipperConfig::load_from_file(config_path).with_context(|| {
842 format!("Failed to load config from: {}", config_path.display())
843 })?)
844 } else {
845 ShipperConfig::load_from_workspace(&planned.workspace_root)
847 .with_context(|| "Failed to load config from workspace")?
848 };
849
850 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 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 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 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 if opts.registries.len() > 1 {
1001 current_opts.state_dir = opts.state_dir.join(®.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 if total_packages > 0 {
1016 let first_pkg = ¤t_planned.plan.packages[0];
1017 progress.set_package(1, &first_pkg.name, &first_pkg.version);
1018 }
1019
1020 reporter.install_progress(progress, package_positions);
1024
1025 let receipt = engine::run_publish(¤t_planned, ¤t_opts, &mut reporter)
1026 .with_context(|| publish_failure_hint(¤t_opts.state_dir))?;
1027
1028 if let Some(progress) = reporter.take_progress() {
1029 progress.finish();
1030 }
1031
1032 print_publish_output(
1033 &receipt,
1034 ¤t_planned.workspace_root,
1035 ¤t_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(®.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 if total_packages > 0 {
1080 let first_pkg = ¤t_planned.plan.packages[0];
1081 progress.set_package(1, &first_pkg.name, &first_pkg.version);
1082 }
1083
1084 reporter.install_progress(progress, package_positions);
1088
1089 let receipt = engine::run_resume(¤t_planned, ¤t_opts, &mut reporter)
1090 .with_context(|| resume_failure_hint(¤t_opts.state_dir))?;
1091
1092 if let Some(progress) = reporter.take_progress() {
1093 progress.finish();
1094 }
1095
1096 print_resume_output(
1097 &receipt,
1098 ¤t_planned.workspace_root,
1099 ¤t_opts.state_dir,
1100 &cli.format,
1101 )?;
1102 }
1103 }
1104 Commands::Rehearse => {
1105 let outcome = engine::run_rehearsal(&planned, &opts, &mut reporter)?;
1106
1107 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 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 ¤t_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(¤t_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(®.api_base)
1186 );
1187 }
1188 let mut current_planned = planned.clone();
1189 current_planned.plan.registry = reg;
1190 doctor::run(¤t_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 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 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 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 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 let plan = if let Some(ref starting) = starting_crate {
1485 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 unreachable!("Config commands should be handled before this match");
1742 }
1743 Commands::Completion { .. } => {
1744 unreachable!("Completion commands should be handled before this match");
1746 }
1747 }
1748
1749 Ok(())
1750}
1751
1752fn 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
1778fn 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
1805fn 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 print_detailed_plan(ws);
2071 } else {
2072 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 let levels = ws.plan.group_by_levels();
2186 let total_levels = levels.len();
2187
2188 println!("=== Dependency Analysis ===");
2189 println!();
2190
2191 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 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 println!("=== Preflight Considerations ===");
2220 println!();
2221
2222 let mut issues: Vec<String> = Vec::new();
2224
2225 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 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 println!("=== Estimated Publishing Analysis ===");
2272 println!();
2273
2274 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 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 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 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 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 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 println!("Workspace Dry-run Failure:");
2400 println!("--------------------------");
2401 println!("{}", rep.dry_run_output.as_ref().unwrap());
2402 println!();
2403 }
2404
2405 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 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 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 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 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 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 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 ®istry.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 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 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 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 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 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 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 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 #[test]
4440 fn preflight_only_flag_parses_and_defaults_to_false() {
4441 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 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 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 use std::time::Instant;
4489 let mut rep = CliReporter::new(true); 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 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 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 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 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 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 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 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 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 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 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}