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