1use governor_core::domain::{
4 changelog::Changelog,
5 commit::{Commit, CommitType},
6 dependency::{DependencyGraph, WorkspaceDependency},
7 version::{BreakingChange, BumpType, Feature, Fix, SemanticVersion},
8 workspace::WorkingTreeStatus,
9};
10use governor_core::traits::{
11 checkpoint_store::{Checkpoint, CheckpointStore},
12 registry::{CratePackage, PublishResult, Registry},
13 source_control::{SourceControl, format_commit_message, format_tag_name},
14};
15use serde::Serialize;
16use std::collections::{HashMap, HashSet};
17use std::path::Path;
18use std::time::Duration;
19use tokio::time::sleep;
20
21use crate::error::{ApplicationError, ApplicationResult};
22use crate::ports::{ChangelogUpdate, CommandPort, ReleasePackage, VersionUpdate, WorkspacePort};
23
24#[derive(Debug, Clone, Serialize)]
26pub struct CheckItem {
27 pub name: String,
29 pub passed: bool,
31 pub exit_code: Option<i32>,
33 pub message: String,
35 pub stdout: String,
37 pub stderr: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct AnalyzeInput {
44 pub workspace_path: String,
46 pub bump_override: Option<String>,
48 pub since: Option<String>,
50 pub risk_analysis: bool,
52 pub allow_version_drift: bool,
54}
55
56#[derive(Debug, Clone, Serialize)]
58pub struct AnalyzeOutput {
59 pub workspace: String,
61 pub current_version: String,
63 pub recommended_bump: BumpType,
65 pub new_version: String,
67 pub confidence: f64,
69 pub reasoning: String,
71 pub commits_analyzed: usize,
73 pub breaking_changes: Vec<BreakingChange>,
75 pub features: Vec<Feature>,
77 pub fixes: Vec<Fix>,
79 pub risk_assessment: Option<RiskAssessment>,
81}
82
83#[derive(Debug, Clone)]
85pub struct PlanInput {
86 pub workspace_path: String,
88 pub include_published: bool,
90 pub allow_version_drift: bool,
92}
93
94#[derive(Debug, Clone, Serialize)]
96pub struct PublicationCrate {
97 pub order: usize,
99 pub name: String,
101 pub version: String,
103 pub dependencies: Vec<String>,
105 pub status: String,
107 pub estimated_publish_time_sec: u64,
109}
110
111#[derive(Debug, Clone, Serialize)]
113pub struct SkippedCrate {
114 pub name: String,
116 pub version: String,
118 pub reason: String,
120}
121
122#[derive(Debug, Clone, Serialize)]
124pub struct PublicationBatch {
125 pub number: usize,
127 pub crates: Vec<String>,
129 pub can_parallelize: bool,
131}
132
133#[derive(Debug, Clone, Serialize)]
135pub struct PlanOutput {
136 pub workspace: String,
138 pub success: bool,
140 pub current_version: String,
142 pub new_version: String,
144 pub bump: BumpType,
146 pub nodes: usize,
148 pub edges: usize,
150 pub publication_order: Vec<PublicationCrate>,
152 pub skipped_crates: Vec<SkippedCrate>,
154 pub batches: Vec<PublicationBatch>,
156}
157
158#[derive(Debug, Clone)]
160pub struct StatusInput {
161 pub workspace_path: String,
163 pub all: bool,
165 pub show_deps: bool,
167}
168
169#[derive(Debug, Clone, Serialize)]
171pub struct StatusCrate {
172 pub name: String,
174 pub version: Option<String>,
176 pub status: String,
178}
179
180#[derive(Debug, Clone, Serialize)]
182pub struct StatusOutput {
183 pub workspace: String,
185 pub current_version: String,
187 pub branch: String,
189 pub last_tag: Option<String>,
191 pub commits_since_tag: usize,
193 pub git: WorkingTreeStatus,
195 pub crates: Vec<StatusCrate>,
197 pub publish_order: Option<Vec<String>>,
199}
200
201#[derive(Debug, Clone)]
203pub struct CheckInput {
204 pub workspace_path: String,
206 pub checks: Option<String>,
208 pub fail_fast: bool,
210}
211
212#[derive(Debug, Clone, Serialize)]
214pub struct CheckOutput {
215 pub success: bool,
217 pub checks: Vec<CheckItem>,
219}
220
221#[derive(Debug, Clone)]
223#[allow(clippy::struct_excessive_bools)]
224pub struct BumpInput {
225 pub workspace_path: String,
227 pub version: Option<String>,
229 pub bump: Option<String>,
231 pub no_changelog: bool,
233 pub no_commit: bool,
235 pub no_tag: bool,
237 pub commit_template: Option<String>,
239 pub tag_template: Option<String>,
241 pub dry_run: bool,
243 pub allow_version_drift: bool,
245 pub allow_dirty: bool,
247}
248
249#[derive(Debug, Clone, Serialize)]
251pub struct BumpOutput {
252 pub workspace: String,
254 pub previous_version: String,
256 pub new_version: String,
258 pub changed: bool,
260 pub files_modified: Vec<String>,
262 pub commit_hash: Option<String>,
264 pub tag_created: bool,
266 pub dry_run: bool,
268}
269
270#[derive(Debug, Clone)]
272pub struct PublishInput {
273 pub workspace_path: String,
275 pub skip_checks: Option<String>,
277 pub only: Option<String>,
279 pub exclude: Option<String>,
281 pub delay: Option<u64>,
283 pub max_retries: Option<usize>,
285 pub on_error: Option<String>,
287 pub dry_run: bool,
289 pub allow_version_drift: bool,
291 pub allow_dirty: bool,
293}
294
295#[derive(Debug, Clone, Serialize)]
297pub struct PublishedCrate {
298 pub name: String,
300 pub version: String,
302 pub publish_time_ms: u64,
304 pub crates_io_url: String,
306}
307
308#[derive(Debug, Clone, Serialize)]
310pub struct PublishOutput {
311 pub workspace: String,
313 pub crates_published: Vec<PublishedCrate>,
315 pub crates_skipped: Vec<SkippedCrate>,
317 pub crates_failed: Vec<(String, String, String)>,
319 pub dry_run: bool,
321 pub checks: Vec<CheckItem>,
323}
324
325#[derive(Debug, Clone)]
327pub struct FullInput {
328 pub workspace_path: String,
330 pub bump: BumpInput,
332 pub publish: PublishInput,
334 pub skip_checks: bool,
336 pub no_push: bool,
338}
339
340#[derive(Debug, Clone, Serialize)]
342pub struct FullOutput {
343 pub workspace: String,
345 pub steps_completed: Vec<String>,
347 pub version: String,
349 pub publish: PublishOutput,
351 pub dry_run: bool,
353}
354
355#[derive(Debug, Clone)]
357pub struct SimulateInput {
358 pub workspace_path: String,
360 pub version: Option<String>,
362 pub downstream_crates: Option<usize>,
364}
365
366#[derive(Debug, Clone, Serialize)]
368pub struct SimulateOutput {
369 pub workspace: String,
371 pub version_diff: HashMap<String, String>,
373 pub breaking_changes: usize,
375 pub features: usize,
377 pub fixes: usize,
379 pub other: usize,
381 pub recommendations: Vec<String>,
383}
384
385#[derive(Debug, Clone)]
387pub struct ResumeInput {
388 pub workspace_path: String,
390 pub checkpoint: Option<String>,
392 pub list: bool,
394 pub clean: bool,
396}
397
398#[derive(Debug, Clone, Serialize)]
400pub struct ResumeOutput {
401 pub workspace: String,
403 pub checkpoints: Vec<Checkpoint>,
405 pub cleared: bool,
407}
408
409#[derive(Debug, Clone, Serialize)]
411pub struct RiskFactor {
412 pub name: String,
414 pub weight: f64,
416 pub reason: String,
418}
419
420#[derive(Debug, Clone, Serialize)]
422pub struct RiskAssessment {
423 pub score: f64,
425 pub level: String,
427 pub factors: Vec<RiskFactor>,
429}
430
431#[derive(Debug, Clone)]
432struct Analysis {
433 breaking: Vec<BreakingCommit>,
434 features: Vec<FeatureCommit>,
435 fixes: Vec<FixCommit>,
436}
437
438#[derive(Debug, Clone)]
439struct BreakingCommit {
440 hash: String,
441 message: String,
442}
443
444#[derive(Debug, Clone)]
445struct FeatureCommit {
446 hash: String,
447 message: String,
448 scope: Option<String>,
449}
450
451#[derive(Debug, Clone)]
452struct FixCommit {
453 hash: String,
454 message: String,
455 scope: Option<String>,
456}
457
458fn analyze_commits(commits: &[Commit]) -> Analysis {
459 let mut analysis = Analysis {
460 breaking: Vec::new(),
461 features: Vec::new(),
462 fixes: Vec::new(),
463 };
464
465 for commit in commits {
466 match commit.commit_type {
467 Some(CommitType::Feat | CommitType::Fix | CommitType::Perf | CommitType::Refactor)
468 if commit.breaking =>
469 {
470 analysis.breaking.push(BreakingCommit {
471 hash: commit.hash.clone(),
472 message: commit.short_message().to_string(),
473 });
474 }
475 Some(CommitType::Feat) => analysis.features.push(FeatureCommit {
476 hash: commit.hash.clone(),
477 message: commit.short_message().to_string(),
478 scope: commit.scope.clone(),
479 }),
480 Some(CommitType::Fix | CommitType::Perf) => analysis.fixes.push(FixCommit {
481 hash: commit.hash.clone(),
482 message: commit.short_message().to_string(),
483 scope: commit.scope.clone(),
484 }),
485 _ => {}
486 }
487 }
488
489 analysis
490}
491
492const fn analyze_bump_type(analysis: &Analysis) -> BumpType {
493 if !analysis.breaking.is_empty() {
494 BumpType::Major
495 } else if !analysis.features.is_empty() {
496 BumpType::Minor
497 } else if !analysis.fixes.is_empty() {
498 BumpType::Patch
499 } else {
500 BumpType::None
501 }
502}
503
504fn build_breaking_changes(commits: Vec<BreakingCommit>, package_name: &str) -> Vec<BreakingChange> {
505 commits
506 .into_iter()
507 .map(|commit| BreakingChange {
508 commit_hash: commit.hash.clone(),
509 short_hash: commit.hash.chars().take(7).collect(),
510 message: commit.message.clone(),
511 breaking_description: commit.message,
512 affected_crates: vec![package_name.to_string()],
513 migration_complexity: governor_core::domain::version::MigrationComplexity::Medium,
514 })
515 .collect()
516}
517
518fn build_features(commits: Vec<FeatureCommit>, package_name: &str) -> Vec<Feature> {
519 commits
520 .into_iter()
521 .map(|commit| Feature {
522 commit_hash: commit.hash.clone(),
523 short_hash: commit.hash.chars().take(7).collect(),
524 message: commit.message,
525 scope: commit.scope,
526 affected_crates: vec![package_name.to_string()],
527 })
528 .collect()
529}
530
531fn build_fixes(commits: Vec<FixCommit>, package_name: &str) -> Vec<Fix> {
532 commits
533 .into_iter()
534 .map(|commit| Fix {
535 commit_hash: commit.hash.clone(),
536 short_hash: commit.hash.chars().take(7).collect(),
537 message: commit.message,
538 scope: commit.scope,
539 affected_crates: vec![package_name.to_string()],
540 })
541 .collect()
542}
543
544fn calculate_confidence(analysis: &Analysis, bump_type: BumpType) -> f64 {
545 let mut score: f64 = match bump_type {
546 BumpType::Major => 0.95,
547 BumpType::Minor => 0.85,
548 BumpType::Patch => 0.75,
549 BumpType::None => 0.50,
550 };
551
552 if !analysis.breaking.is_empty() {
553 score += 0.03;
554 }
555 if analysis.features.len() > 3 || analysis.fixes.len() > 5 {
556 score += 0.02;
557 }
558
559 score.clamp(0.0, 1.0)
560}
561
562fn generate_reasoning(
563 analysis: &Analysis,
564 bump_type: BumpType,
565 current: &SemanticVersion,
566 recommended: &SemanticVersion,
567) -> String {
568 match bump_type {
569 BumpType::Major => format!(
570 "{} breaking changes detected. Version should advance from {current} to {recommended}.",
571 analysis.breaking.len()
572 ),
573 BumpType::Minor => format!(
574 "{} feature commits detected. Version should advance from {current} to {recommended}.",
575 analysis.features.len()
576 ),
577 BumpType::Patch => format!(
578 "{} fix/perf commits detected. Version should advance from {current} to {recommended}.",
579 analysis.fixes.len()
580 ),
581 BumpType::None => format!("No significant changes detected. Version remains at {current}."),
582 }
583}
584
585fn calculate_risk_assessment(
586 breaking_changes: &[BreakingChange],
587 features: &[Feature],
588 fixes: &[Fix],
589) -> RiskAssessment {
590 let mut factors = Vec::new();
591 let mut score = 0.0;
592
593 if !breaking_changes.is_empty() {
594 score += 0.65;
595 factors.push(RiskFactor {
596 name: "breaking_changes".to_string(),
597 weight: 0.65,
598 reason: "Breaking changes usually require migration work.".to_string(),
599 });
600 }
601 if features.len() > 2 {
602 score += 0.20;
603 factors.push(RiskFactor {
604 name: "feature_count".to_string(),
605 weight: 0.20,
606 reason: "Multiple feature commits raise change surface area.".to_string(),
607 });
608 }
609 if fixes.len() > 5 {
610 score += 0.10;
611 factors.push(RiskFactor {
612 name: "fix_volume".to_string(),
613 weight: 0.10,
614 reason: "High fix count often indicates churn before release.".to_string(),
615 });
616 }
617
618 let level = if score >= 0.75 {
619 "high"
620 } else if score >= 0.35 {
621 "medium"
622 } else {
623 "low"
624 };
625
626 RiskAssessment {
627 score,
628 level: level.to_string(),
629 factors,
630 }
631}
632
633fn build_dependency_graph(packages: &[ReleasePackage]) -> DependencyGraph {
634 let mut graph = DependencyGraph::new();
635
636 for pkg in packages {
637 for dependency in &pkg.dependencies {
638 graph.add(WorkspaceDependency::new(
639 pkg.name.clone(),
640 dependency.clone(),
641 "workspace".to_string(),
642 ));
643 }
644 }
645
646 graph
647}
648
649fn build_batches(order: &[String]) -> Vec<PublicationBatch> {
650 order
651 .iter()
652 .enumerate()
653 .map(|(idx, name)| PublicationBatch {
654 number: idx + 1,
655 crates: vec![name.clone()],
656 can_parallelize: false,
657 })
658 .collect()
659}
660
661fn default_checks(checks: Option<&str>) -> Vec<String> {
662 checks.map_or_else(
663 || {
664 vec![
665 "test".to_string(),
666 "clippy".to_string(),
667 "fmt".to_string(),
668 "doc".to_string(),
669 "build".to_string(),
670 ]
671 },
672 |csv| {
673 csv.split(',')
674 .map(str::trim)
675 .filter(|value| !value.is_empty())
676 .map(ToOwned::to_owned)
677 .collect()
678 },
679 )
680}
681
682fn normalize_scope(commits: &[Commit]) -> HashSet<String> {
683 commits
684 .iter()
685 .filter_map(|commit| commit.scope.clone())
686 .collect()
687}
688
689fn parse_bump_override(value: Option<&str>, analysis: &Analysis) -> ApplicationResult<BumpType> {
690 match value {
691 Some("major") => Ok(BumpType::Major),
692 Some("minor") => Ok(BumpType::Minor),
693 Some("patch") => Ok(BumpType::Patch),
694 Some("none") => Ok(BumpType::None),
695 Some("auto") | None => Ok(analyze_bump_type(analysis)),
696 Some(other) => Err(ApplicationError::InvalidArguments(format!(
697 "unsupported bump strategy `{other}`"
698 ))),
699 }
700}
701
702fn parse_target_version(
703 current: &SemanticVersion,
704 version: Option<&str>,
705) -> ApplicationResult<SemanticVersion> {
706 version.map_or_else(
707 || Ok(current.clone()),
708 |value| {
709 SemanticVersion::parse(value).map_err(|error| {
710 ApplicationError::InvalidArguments(format!(
711 "failed to parse semantic version `{value}`: {error}"
712 ))
713 })
714 },
715 )
716}
717
718async fn release_commits_since_last_tag<S: SourceControl>(
719 source_control: &S,
720) -> ApplicationResult<Vec<Commit>> {
721 let last_tag = source_control.get_last_tag(Some("v")).await?;
722 source_control
723 .get_commits_since(last_tag.as_deref())
724 .await
725 .map_err(Into::into)
726}
727
728fn changelog_from_commits(version: &SemanticVersion, commits: &[Commit]) -> Changelog {
729 Changelog::from_commits(version.clone(), commits)
730}
731
732fn parse_release_tag_version(tag: &str) -> Option<SemanticVersion> {
733 let normalized = tag.strip_prefix('v').unwrap_or(tag);
734 SemanticVersion::parse(normalized).ok()
735}
736
737fn version_drift_message(current: &SemanticVersion, last_tag: &str) -> Option<String> {
738 let tagged_version = parse_release_tag_version(last_tag)?;
739 if current == &tagged_version {
740 return None;
741 }
742
743 let relation = match current.version.cmp(&tagged_version.version) {
744 std::cmp::Ordering::Greater => "ahead of",
745 std::cmp::Ordering::Less => "behind",
746 std::cmp::Ordering::Equal => return None,
747 };
748
749 Some(format!(
750 "workspace version {current} is {relation} last release tag {last_tag}. Commit your code changes first and let cargo-governor perform the release bump/changelog/tag step for you. Use --allow-version-drift only when you intentionally need to override this safety check."
751 ))
752}
753
754async fn enforce_version_alignment<S: SourceControl>(
755 source_control: &S,
756 current_version: &SemanticVersion,
757 allow_version_drift: bool,
758) -> ApplicationResult<()> {
759 if allow_version_drift {
760 return Ok(());
761 }
762
763 let Some(last_tag) = source_control.get_last_tag(Some("v")).await? else {
764 return Ok(());
765 };
766
767 if let Some(message) = version_drift_message(current_version, &last_tag) {
768 return Err(ApplicationError::InvalidArguments(message));
769 }
770
771 Ok(())
772}
773
774async fn enforce_clean_worktree<S: SourceControl>(
775 source_control: &S,
776 allow_dirty: bool,
777) -> ApplicationResult<()> {
778 if allow_dirty {
779 return Ok(());
780 }
781
782 let status = source_control.get_working_tree_status().await?;
783 if status.is_clean() {
784 return Ok(());
785 }
786
787 let mut changed_files = status
788 .modified
789 .iter()
790 .chain(status.added.iter())
791 .chain(status.deleted.iter())
792 .chain(status.untracked.iter())
793 .take(5)
794 .cloned()
795 .collect::<Vec<_>>();
796 if status.total_changes() + status.untracked.len() > changed_files.len() {
797 changed_files.push("...".to_string());
798 }
799
800 Err(ApplicationError::InvalidArguments(format!(
801 "working tree is dirty. Commit or stash your changes before running a mutating release command so cargo-governor can own the release commit. Changed files: {}. Use --allow-dirty only when you intentionally need to override this safety check.",
802 changed_files.join(", ")
803 )))
804}
805
806pub async fn analyze<W: WorkspacePort, S: SourceControl>(
813 workspace_port: &W,
814 source_control: &S,
815 input: AnalyzeInput,
816) -> ApplicationResult<AnalyzeOutput> {
817 let workspace = workspace_port
818 .load_release_workspace(Path::new(&input.workspace_path))
819 .await?;
820 enforce_version_alignment(
821 source_control,
822 &workspace.current_version,
823 input.allow_version_drift,
824 )
825 .await?;
826 let commits = if let Some(since) = input.since.as_deref() {
827 source_control.get_commits_since(Some(since)).await?
828 } else {
829 release_commits_since_last_tag(source_control).await?
830 };
831 let analysis = analyze_commits(&commits);
832 let bump_type = parse_bump_override(input.bump_override.as_deref(), &analysis)?;
833 let recommended = bump_type.apply_to(&workspace.current_version);
834 let breaking_changes = build_breaking_changes(analysis.breaking.clone(), &workspace.name);
835 let features = build_features(analysis.features.clone(), &workspace.name);
836 let fixes = build_fixes(analysis.fixes.clone(), &workspace.name);
837 let confidence = calculate_confidence(&analysis, bump_type);
838 let reasoning = generate_reasoning(
839 &analysis,
840 bump_type,
841 &workspace.current_version,
842 &recommended,
843 );
844 let risk_assessment = input
845 .risk_analysis
846 .then(|| calculate_risk_assessment(&breaking_changes, &features, &fixes));
847
848 Ok(AnalyzeOutput {
849 workspace: workspace.name,
850 current_version: workspace.current_version.to_string(),
851 recommended_bump: bump_type,
852 new_version: recommended.to_string(),
853 confidence,
854 reasoning,
855 commits_analyzed: commits.len(),
856 breaking_changes,
857 features,
858 fixes,
859 risk_assessment,
860 })
861}
862
863pub async fn plan<W: WorkspacePort, S: SourceControl, R: Registry>(
870 workspace_port: &W,
871 source_control: &S,
872 registry: &R,
873 input: PlanInput,
874) -> ApplicationResult<PlanOutput> {
875 let workspace = workspace_port
876 .load_release_workspace(Path::new(&input.workspace_path))
877 .await?;
878 enforce_version_alignment(
879 source_control,
880 &workspace.current_version,
881 input.allow_version_drift,
882 )
883 .await?;
884 let commits = release_commits_since_last_tag(source_control).await?;
885 let analysis = analyze_commits(&commits);
886 let bump = analyze_bump_type(&analysis);
887 let new_version = bump.apply_to(&workspace.current_version);
888 let graph = build_dependency_graph(&workspace.packages);
889 let order = graph.publish_order().unwrap_or_default();
890 let edges = graph.dependencies.len();
891 let nodes = graph.all_crates().len();
892
893 let mut skipped = Vec::new();
894 let mut publication_order = Vec::new();
895 for name in &order {
896 if let Some(package) = workspace
897 .packages
898 .iter()
899 .find(|package| &package.name == name)
900 {
901 let published = registry
902 .is_published(&package.name, &package.version)
903 .await?;
904 if published && !input.include_published {
905 skipped.push(SkippedCrate {
906 name: package.name.clone(),
907 version: package.version.to_string(),
908 reason: "already_published".to_string(),
909 });
910 continue;
911 }
912
913 publication_order.push(PublicationCrate {
914 order: publication_order.len() + 1,
915 name: package.name.clone(),
916 version: new_version.to_string(),
917 dependencies: package.dependencies.clone(),
918 status: "ready".to_string(),
919 estimated_publish_time_sec: 45,
920 });
921 }
922 }
923
924 let batches = build_batches(
925 &publication_order
926 .iter()
927 .map(|item| item.name.clone())
928 .collect::<Vec<_>>(),
929 );
930
931 Ok(PlanOutput {
932 workspace: workspace.name,
933 success: !graph.has_cycles(),
934 current_version: workspace.current_version.to_string(),
935 new_version: new_version.to_string(),
936 bump,
937 nodes,
938 edges,
939 publication_order,
940 skipped_crates: skipped,
941 batches,
942 })
943}
944
945pub async fn status<W: WorkspacePort, S: SourceControl>(
952 workspace_port: &W,
953 source_control: &S,
954 input: StatusInput,
955) -> ApplicationResult<StatusOutput> {
956 let workspace = workspace_port
957 .load_release_workspace(Path::new(&input.workspace_path))
958 .await?;
959 let branch = source_control.get_current_branch().await?;
960 let last_tag = source_control.get_last_tag(Some("v")).await?;
961 let commits_since_tag = if let Some(tag) = &last_tag {
962 source_control.get_commits_since(Some(tag)).await?
963 } else {
964 Vec::new()
965 };
966 let git = source_control.get_working_tree_status().await?;
967 let changed_scopes = normalize_scope(&commits_since_tag);
968
969 let crates = if input.all {
970 workspace
971 .packages
972 .iter()
973 .map(|package| StatusCrate {
974 name: package.name.clone(),
975 version: Some(package.version.to_string()),
976 status: if changed_scopes.contains(&package.name) {
977 "changed".to_string()
978 } else {
979 "unchanged".to_string()
980 },
981 })
982 .collect()
983 } else {
984 changed_scopes
985 .into_iter()
986 .map(|name| StatusCrate {
987 name,
988 version: None,
989 status: "changed".to_string(),
990 })
991 .collect()
992 };
993
994 let publish_order = input.show_deps.then(|| {
995 build_dependency_graph(&workspace.packages)
996 .publish_order()
997 .unwrap_or_default()
998 });
999
1000 Ok(StatusOutput {
1001 workspace: workspace.name,
1002 current_version: workspace.current_version.to_string(),
1003 branch,
1004 last_tag,
1005 commits_since_tag: commits_since_tag.len(),
1006 git,
1007 crates,
1008 publish_order,
1009 })
1010}
1011
1012pub async fn check<C: CommandPort>(
1019 command_port: &C,
1020 input: CheckInput,
1021) -> ApplicationResult<CheckOutput> {
1022 let mut checks = Vec::new();
1023 let mut success = true;
1024
1025 for check_name in default_checks(input.checks.as_deref()) {
1026 let report = command_port
1027 .run_check(Path::new(&input.workspace_path), &check_name, true)
1028 .await?;
1029 let item = CheckItem {
1030 name: report.name,
1031 passed: report.success,
1032 exit_code: report.exit_code,
1033 message: if report.success {
1034 "Passed".to_string()
1035 } else {
1036 format!("Check `{check_name}` failed")
1037 },
1038 stdout: report.stdout,
1039 stderr: report.stderr,
1040 };
1041
1042 success &= item.passed;
1043 checks.push(item);
1044
1045 if input.fail_fast && !success {
1046 break;
1047 }
1048 }
1049
1050 Ok(CheckOutput { success, checks })
1051}
1052
1053fn version_from_commits(
1054 current: &SemanticVersion,
1055 commits: &[Commit],
1056 bump_override: Option<&str>,
1057) -> ApplicationResult<SemanticVersion> {
1058 let analysis = analyze_commits(commits);
1059 let bump = parse_bump_override(bump_override, &analysis)?;
1060 Ok(bump.apply_to(current))
1061}
1062
1063pub async fn bump<W: WorkspacePort, S: SourceControl>(
1070 workspace_port: &W,
1071 source_control: &S,
1072 input: BumpInput,
1073) -> ApplicationResult<BumpOutput> {
1074 let workspace = workspace_port
1075 .load_release_workspace(Path::new(&input.workspace_path))
1076 .await?;
1077 enforce_version_alignment(
1078 source_control,
1079 &workspace.current_version,
1080 input.allow_version_drift,
1081 )
1082 .await?;
1083 if !input.dry_run {
1084 enforce_clean_worktree(source_control, input.allow_dirty).await?;
1085 }
1086
1087 let target_version = if let Some(version) = input.version.as_deref() {
1088 parse_target_version(&workspace.current_version, Some(version))?
1089 } else {
1090 let commits = release_commits_since_last_tag(source_control).await?;
1091 version_from_commits(&workspace.current_version, &commits, input.bump.as_deref())?
1092 };
1093
1094 let changed = target_version != workspace.current_version;
1095 let version_update = if changed {
1096 workspace_port
1097 .update_workspace_version(
1098 Path::new(&input.workspace_path),
1099 &target_version,
1100 input.dry_run,
1101 )
1102 .await?
1103 } else {
1104 VersionUpdate {
1105 modified_files: Vec::new(),
1106 }
1107 };
1108
1109 let changelog_update = if input.no_changelog || !changed {
1110 ChangelogUpdate {
1111 updated: false,
1112 modified_files: Vec::new(),
1113 }
1114 } else {
1115 let commits = release_commits_since_last_tag(source_control).await?;
1116 let changelog = changelog_from_commits(&target_version, &commits);
1117 workspace_port
1118 .update_changelog(Path::new(&input.workspace_path), &changelog, input.dry_run)
1119 .await?
1120 };
1121
1122 let mut files_modified = version_update.modified_files.clone();
1123 for file in changelog_update.modified_files {
1124 if !files_modified.contains(&file) {
1125 files_modified.push(file);
1126 }
1127 }
1128
1129 let commit_hash = if changed && !input.no_commit && !input.dry_run {
1130 let template = input
1131 .commit_template
1132 .as_deref()
1133 .unwrap_or("chore(release): bump version to {{version}}");
1134 let message = format_commit_message(template, &target_version);
1135 source_control.commit(&message, &files_modified).await.ok()
1136 } else {
1137 None
1138 };
1139
1140 let tag_created = if changed && !input.no_tag && !input.dry_run {
1141 let template = input.tag_template.as_deref().unwrap_or("v{{version}}");
1142 let tag_name = format_tag_name(template, &target_version);
1143 source_control
1144 .create_tag(&tag_name, &format!("Release {target_version}"))
1145 .await
1146 .is_ok()
1147 } else {
1148 false
1149 };
1150
1151 Ok(BumpOutput {
1152 workspace: workspace.name,
1153 previous_version: workspace.current_version.to_string(),
1154 new_version: target_version.to_string(),
1155 changed,
1156 files_modified,
1157 commit_hash,
1158 tag_created,
1159 dry_run: input.dry_run,
1160 })
1161}
1162
1163fn parse_csv_filter(values: Option<&str>) -> HashSet<String> {
1164 values
1165 .map(|csv| {
1166 csv.split(',')
1167 .map(str::trim)
1168 .filter(|value| !value.is_empty())
1169 .map(ToOwned::to_owned)
1170 .collect()
1171 })
1172 .unwrap_or_default()
1173}
1174
1175async fn run_publish_checks<C: CommandPort>(
1176 command_port: &C,
1177 workspace_path: &str,
1178 checks_to_skip: &HashSet<String>,
1179) -> ApplicationResult<Vec<CheckItem>> {
1180 let mut checks = Vec::new();
1181
1182 for check_name in default_checks(None) {
1183 if checks_to_skip.contains(&check_name) {
1184 continue;
1185 }
1186
1187 let report = command_port
1188 .run_check(Path::new(workspace_path), &check_name, true)
1189 .await?;
1190 let item = CheckItem {
1191 name: report.name,
1192 passed: report.success,
1193 exit_code: report.exit_code,
1194 message: if report.success {
1195 "Passed".to_string()
1196 } else {
1197 format!("Check `{check_name}` failed")
1198 },
1199 stdout: report.stdout,
1200 stderr: report.stderr,
1201 };
1202
1203 if !item.passed {
1204 return Err(ApplicationError::InvalidArguments(item.message));
1205 }
1206
1207 checks.push(item);
1208 }
1209
1210 Ok(checks)
1211}
1212
1213fn ordered_publish_packages(
1214 workspace: &crate::ports::ReleaseWorkspace,
1215 input: &PublishInput,
1216) -> Vec<ReleasePackage> {
1217 let only = parse_csv_filter(input.only.as_deref());
1218 let exclude = parse_csv_filter(input.exclude.as_deref());
1219 let mut packages = workspace
1220 .packages
1221 .iter()
1222 .filter(|package| package.publish)
1223 .filter(|package| only.is_empty() || only.contains(&package.name))
1224 .filter(|package| !exclude.contains(&package.name))
1225 .cloned()
1226 .collect::<Vec<_>>();
1227 let graph = build_dependency_graph(&packages);
1228 let ordered_names = graph.publish_order().unwrap_or_default();
1229 packages.sort_by_key(|package| {
1230 ordered_names
1231 .iter()
1232 .position(|name| name == &package.name)
1233 .unwrap_or(usize::MAX)
1234 });
1235 packages
1236}
1237
1238fn build_registry_package(package: &ReleasePackage, dry_run: bool) -> CratePackage {
1239 CratePackage {
1240 name: package.name.clone(),
1241 version: package.version.clone(),
1242 crate_file: Path::new("").to_path_buf(),
1243 manifest_path: package.manifest_path.clone(),
1244 token: String::new(),
1245 dry_run,
1246 }
1247}
1248
1249async fn publish_package_with_retry<R: Registry>(
1250 registry: &R,
1251 package: &ReleasePackage,
1252 input: &PublishInput,
1253) -> Result<PublishResult, String> {
1254 let crate_data = build_registry_package(package, input.dry_run);
1255 let max_retries = input.max_retries.unwrap_or(3);
1256 let mut last_error = None;
1257
1258 for attempt in 0..max_retries {
1259 match registry.publish(&crate_data).await {
1260 Ok(result) => return Ok(result),
1261 Err(error) => {
1262 last_error = Some(error.to_string());
1263 if attempt + 1 < max_retries {
1264 sleep(Duration::from_secs(5)).await;
1265 }
1266 }
1267 }
1268 }
1269
1270 Err(last_error.unwrap_or_else(|| "publish failed".to_string()))
1271}
1272
1273async fn publish_selected_packages<R: Registry>(
1274 registry: &R,
1275 packages: Vec<ReleasePackage>,
1276 input: &PublishInput,
1277) -> ApplicationResult<(
1278 Vec<PublishedCrate>,
1279 Vec<SkippedCrate>,
1280 Vec<(String, String, String)>,
1281)> {
1282 let mut published = Vec::new();
1283 let mut skipped = Vec::new();
1284 let mut failed = Vec::new();
1285 let continue_on_error = matches!(input.on_error.as_deref(), Some("skip"));
1286 let publish_interval = input.delay.unwrap_or(2);
1287
1288 for (index, package) in packages.iter().enumerate() {
1289 if registry
1290 .is_published(&package.name, &package.version)
1291 .await?
1292 {
1293 skipped.push(SkippedCrate {
1294 name: package.name.clone(),
1295 version: package.version.to_string(),
1296 reason: "already_published".to_string(),
1297 });
1298 continue;
1299 }
1300
1301 match publish_package_with_retry(registry, package, input).await {
1302 Ok(result) => {
1303 published.push(PublishedCrate {
1304 name: result.crate_name,
1305 version: result.version.to_string(),
1306 publish_time_ms: result.duration_ms,
1307 crates_io_url: result.crates_io_url,
1308 });
1309 let remaining_packages = &packages[index + 1..];
1310 let has_dependent_follow_up = remaining_packages
1311 .iter()
1312 .any(|candidate| candidate.dependencies.contains(&package.name));
1313 if !input.dry_run && has_dependent_follow_up {
1314 wait_for_registry_visibility(
1315 registry,
1316 package,
1317 Duration::from_secs(publish_interval),
1318 Duration::from_secs(60),
1319 )
1320 .await?;
1321 }
1322 }
1323 Err(error) => {
1324 failed.push((package.name.clone(), package.version.to_string(), error));
1325 if !continue_on_error {
1326 break;
1327 }
1328 }
1329 }
1330 }
1331
1332 Ok((published, skipped, failed))
1333}
1334
1335async fn wait_for_registry_visibility<R: Registry>(
1336 registry: &R,
1337 package: &ReleasePackage,
1338 poll_interval: Duration,
1339 timeout: Duration,
1340) -> ApplicationResult<()> {
1341 let started_at = std::time::Instant::now();
1342
1343 loop {
1344 if registry
1345 .is_published(&package.name, &package.version)
1346 .await?
1347 {
1348 return Ok(());
1349 }
1350
1351 if started_at.elapsed() >= timeout {
1352 return Err(governor_core::traits::registry::RegistryError::ApiError(format!(
1353 "crate `{}` version {} was published locally but did not become visible in the registry within {} seconds",
1354 package.name,
1355 package.version,
1356 timeout.as_secs()
1357 ))
1358 .into());
1359 }
1360
1361 sleep(poll_interval).await;
1362 }
1363}
1364
1365pub async fn publish<W: WorkspacePort, C: CommandPort, S: SourceControl, R: Registry>(
1372 workspace_port: &W,
1373 command_port: &C,
1374 source_control: &S,
1375 registry: &R,
1376 input: PublishInput,
1377) -> ApplicationResult<PublishOutput> {
1378 let workspace = workspace_port
1379 .load_release_workspace(Path::new(&input.workspace_path))
1380 .await?;
1381 enforce_version_alignment(
1382 source_control,
1383 &workspace.current_version,
1384 input.allow_version_drift,
1385 )
1386 .await?;
1387 if !input.dry_run {
1388 enforce_clean_worktree(source_control, input.allow_dirty).await?;
1389 }
1390 let checks_to_skip = parse_csv_filter(input.skip_checks.as_deref());
1391 let checks =
1392 match run_publish_checks(command_port, &input.workspace_path, &checks_to_skip).await {
1393 Ok(checks) => checks,
1394 Err(error) => {
1395 return Ok(PublishOutput {
1396 workspace: workspace.name,
1397 crates_published: Vec::new(),
1398 crates_skipped: Vec::new(),
1399 crates_failed: vec![(
1400 "checks".to_string(),
1401 "checks".to_string(),
1402 error.to_string(),
1403 )],
1404 dry_run: input.dry_run,
1405 checks: Vec::new(),
1406 });
1407 }
1408 };
1409 let packages = ordered_publish_packages(&workspace, &input);
1410 let (published, skipped, failed) =
1411 publish_selected_packages(registry, packages, &input).await?;
1412
1413 Ok(PublishOutput {
1414 workspace: workspace.name,
1415 crates_published: published,
1416 crates_skipped: skipped,
1417 crates_failed: failed,
1418 dry_run: input.dry_run,
1419 checks,
1420 })
1421}
1422
1423pub async fn full<W: WorkspacePort, C: CommandPort, S: SourceControl, R: Registry>(
1430 workspace_port: &W,
1431 command_port: &C,
1432 source_control: &S,
1433 registry: &R,
1434 input: FullInput,
1435) -> ApplicationResult<FullOutput> {
1436 if !input.publish.dry_run {
1437 enforce_clean_worktree(source_control, input.bump.allow_dirty).await?;
1438 }
1439 let mut steps_completed = Vec::new();
1440 if !input.skip_checks {
1441 let checks = check(
1442 command_port,
1443 CheckInput {
1444 workspace_path: input.workspace_path.clone(),
1445 checks: None,
1446 fail_fast: true,
1447 },
1448 )
1449 .await?;
1450 if !checks.success {
1451 return Err(ApplicationError::InvalidArguments(
1452 "pre-publish checks failed".to_string(),
1453 ));
1454 }
1455 steps_completed.push("check".to_string());
1456 }
1457
1458 let bump_output = bump(workspace_port, source_control, input.bump.clone()).await?;
1459 steps_completed.push("bump".to_string());
1460 if bump_output.commit_hash.is_some() {
1461 steps_completed.push("commit".to_string());
1462 }
1463 if bump_output.tag_created {
1464 steps_completed.push("tag".to_string());
1465 }
1466
1467 let publish_output = publish(
1468 workspace_port,
1469 command_port,
1470 source_control,
1471 registry,
1472 input.publish.clone(),
1473 )
1474 .await?;
1475 steps_completed.push("publish".to_string());
1476
1477 if !input.no_push && !input.publish.dry_run {
1478 source_control.push(None, None).await?;
1479 steps_completed.push("push".to_string());
1480 }
1481
1482 Ok(FullOutput {
1483 workspace: bump_output.workspace,
1484 steps_completed,
1485 version: bump_output.new_version,
1486 publish: publish_output,
1487 dry_run: input.publish.dry_run,
1488 })
1489}
1490
1491pub async fn simulate<W: WorkspacePort, S: SourceControl>(
1498 workspace_port: &W,
1499 source_control: &S,
1500 input: SimulateInput,
1501) -> ApplicationResult<SimulateOutput> {
1502 let workspace = workspace_port
1503 .load_release_workspace(Path::new(&input.workspace_path))
1504 .await?;
1505 let commits = release_commits_since_last_tag(source_control).await?;
1506 let analysis = analyze_commits(&commits);
1507 let target_version = parse_target_version(&workspace.current_version, input.version.as_deref())
1508 .or_else(|_| version_from_commits(&workspace.current_version, &commits, None))?;
1509 let mut version_diff = HashMap::new();
1510 version_diff.insert("current".to_string(), workspace.current_version.to_string());
1511 version_diff.insert("target".to_string(), target_version.to_string());
1512 version_diff.insert(
1513 "bump_type".to_string(),
1514 if target_version.major() > workspace.current_version.major() {
1515 "major"
1516 } else if target_version.minor() > workspace.current_version.minor() {
1517 "minor"
1518 } else if target_version.patch() > workspace.current_version.patch() {
1519 "patch"
1520 } else {
1521 "none"
1522 }
1523 .to_string(),
1524 );
1525
1526 Ok(SimulateOutput {
1527 workspace: workspace.name,
1528 version_diff,
1529 breaking_changes: analysis.breaking.len(),
1530 features: analysis.features.len(),
1531 fixes: analysis.fixes.len(),
1532 other: commits.len().saturating_sub(
1533 analysis.breaking.len() + analysis.features.len() + analysis.fixes.len(),
1534 ),
1535 recommendations: {
1536 let mut values = Vec::new();
1537 if !analysis.breaking.is_empty() {
1538 values.push("Update migration notes before release.".to_string());
1539 }
1540 if input.downstream_crates.unwrap_or(0) > 0 && !analysis.breaking.is_empty() {
1541 values.push(
1542 "Notify downstream maintainers about a potentially breaking release."
1543 .to_string(),
1544 );
1545 }
1546 if values.is_empty() {
1547 values.push("No special simulation recommendations.".to_string());
1548 }
1549 values
1550 },
1551 })
1552}
1553
1554pub async fn resume<S: CheckpointStore>(
1561 store: &S,
1562 input: ResumeInput,
1563) -> ApplicationResult<ResumeOutput> {
1564 if input.clean {
1565 store.clear().await?;
1566 return Ok(ResumeOutput {
1567 workspace: input.workspace_path,
1568 checkpoints: Vec::new(),
1569 cleared: true,
1570 });
1571 }
1572
1573 if input.list {
1574 let checkpoints = store
1575 .list()
1576 .await?
1577 .into_iter()
1578 .map(|info| Checkpoint {
1579 id: info.id,
1580 workflow_id: info.workflow_id,
1581 step_index: info.step_index,
1582 completed_steps: Vec::new(),
1583 state: serde_json::Value::Null,
1584 target_version: None,
1585 timestamp: info.timestamp,
1586 interrupted_by_error: info.has_error,
1587 error_message: None,
1588 })
1589 .collect();
1590 return Ok(ResumeOutput {
1591 workspace: input.workspace_path,
1592 checkpoints,
1593 cleared: false,
1594 });
1595 }
1596
1597 let checkpoint = if let Some(id) = input.checkpoint.as_deref() {
1598 store.load_by_id(id).await?
1599 } else {
1600 store.load().await?
1601 };
1602
1603 Ok(ResumeOutput {
1604 workspace: input.workspace_path,
1605 checkpoints: checkpoint.into_iter().collect(),
1606 cleared: false,
1607 })
1608}