Skip to main content

governor_application/
release.rs

1//! Release use cases.
2
3use 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/// Generic check result.
25#[derive(Debug, Clone, Serialize)]
26pub struct CheckItem {
27    /// Check name.
28    pub name: String,
29    /// Whether it passed.
30    pub passed: bool,
31    /// Exit code when available.
32    pub exit_code: Option<i32>,
33    /// Human-readable message.
34    pub message: String,
35    /// Captured stdout.
36    pub stdout: String,
37    /// Captured stderr.
38    pub stderr: String,
39}
40
41/// Analyze input.
42#[derive(Debug, Clone)]
43pub struct AnalyzeInput {
44    /// Workspace root.
45    pub workspace_path: String,
46    /// Optional bump override.
47    pub bump_override: Option<String>,
48    /// Analyze since a specific tag.
49    pub since: Option<String>,
50    /// Include risk analysis.
51    pub risk_analysis: bool,
52    /// Allow workspace version drift from the last release tag.
53    pub allow_version_drift: bool,
54}
55
56/// Analyze output.
57#[derive(Debug, Clone, Serialize)]
58pub struct AnalyzeOutput {
59    /// Workspace display name.
60    pub workspace: String,
61    /// Current version.
62    pub current_version: String,
63    /// Recommended bump.
64    pub recommended_bump: BumpType,
65    /// Target version.
66    pub new_version: String,
67    /// Confidence score.
68    pub confidence: f64,
69    /// Human-readable reasoning.
70    pub reasoning: String,
71    /// Commits inspected.
72    pub commits_analyzed: usize,
73    /// Breaking changes.
74    pub breaking_changes: Vec<BreakingChange>,
75    /// Features.
76    pub features: Vec<Feature>,
77    /// Fixes.
78    pub fixes: Vec<Fix>,
79    /// Optional risk assessment.
80    pub risk_assessment: Option<RiskAssessment>,
81}
82
83/// Planning input.
84#[derive(Debug, Clone)]
85pub struct PlanInput {
86    /// Workspace root.
87    pub workspace_path: String,
88    /// Include already-published crates.
89    pub include_published: bool,
90    /// Allow workspace version drift from the last release tag.
91    pub allow_version_drift: bool,
92}
93
94/// Published crate entry for plan.
95#[derive(Debug, Clone, Serialize)]
96pub struct PublicationCrate {
97    /// Order index.
98    pub order: usize,
99    /// Crate name.
100    pub name: String,
101    /// Target version.
102    pub version: String,
103    /// Internal dependencies.
104    pub dependencies: Vec<String>,
105    /// Status.
106    pub status: String,
107    /// Estimated publish time.
108    pub estimated_publish_time_sec: u64,
109}
110
111/// Skipped crate entry for plan or publish.
112#[derive(Debug, Clone, Serialize)]
113pub struct SkippedCrate {
114    /// Crate name.
115    pub name: String,
116    /// Version.
117    pub version: String,
118    /// Skip reason.
119    pub reason: String,
120}
121
122/// Batch entry for plan.
123#[derive(Debug, Clone, Serialize)]
124pub struct PublicationBatch {
125    /// Batch number.
126    pub number: usize,
127    /// Included crates.
128    pub crates: Vec<String>,
129    /// Whether it can run in parallel.
130    pub can_parallelize: bool,
131}
132
133/// Plan output.
134#[derive(Debug, Clone, Serialize)]
135pub struct PlanOutput {
136    /// Workspace display name.
137    pub workspace: String,
138    /// Whether graph is acyclic.
139    pub success: bool,
140    /// Current version.
141    pub current_version: String,
142    /// New version recommendation.
143    pub new_version: String,
144    /// Bump type.
145    pub bump: BumpType,
146    /// Graph node count.
147    pub nodes: usize,
148    /// Graph edge count.
149    pub edges: usize,
150    /// Publication order.
151    pub publication_order: Vec<PublicationCrate>,
152    /// Skipped crates.
153    pub skipped_crates: Vec<SkippedCrate>,
154    /// Publication batches.
155    pub batches: Vec<PublicationBatch>,
156}
157
158/// Status input.
159#[derive(Debug, Clone)]
160pub struct StatusInput {
161    /// Workspace root.
162    pub workspace_path: String,
163    /// Include unchanged crates.
164    pub all: bool,
165    /// Include dependency graph.
166    pub show_deps: bool,
167}
168
169/// Crate status entry.
170#[derive(Debug, Clone, Serialize)]
171pub struct StatusCrate {
172    /// Name.
173    pub name: String,
174    /// Version when known.
175    pub version: Option<String>,
176    /// Status string.
177    pub status: String,
178}
179
180/// Status output.
181#[derive(Debug, Clone, Serialize)]
182pub struct StatusOutput {
183    /// Workspace name.
184    pub workspace: String,
185    /// Workspace version.
186    pub current_version: String,
187    /// Current branch.
188    pub branch: String,
189    /// Last tag.
190    pub last_tag: Option<String>,
191    /// Commits since last tag.
192    pub commits_since_tag: usize,
193    /// Working tree status.
194    pub git: WorkingTreeStatus,
195    /// Crate statuses.
196    pub crates: Vec<StatusCrate>,
197    /// Optional dependency order.
198    pub publish_order: Option<Vec<String>>,
199}
200
201/// Check input.
202#[derive(Debug, Clone)]
203pub struct CheckInput {
204    /// Workspace root.
205    pub workspace_path: String,
206    /// Optional checks CSV.
207    pub checks: Option<String>,
208    /// Fail fast flag.
209    pub fail_fast: bool,
210}
211
212/// Check output.
213#[derive(Debug, Clone, Serialize)]
214pub struct CheckOutput {
215    /// All checks passed.
216    pub success: bool,
217    /// Per-check results.
218    pub checks: Vec<CheckItem>,
219}
220
221/// Bump input.
222#[derive(Debug, Clone)]
223#[allow(clippy::struct_excessive_bools)]
224pub struct BumpInput {
225    /// Workspace root.
226    pub workspace_path: String,
227    /// Force version.
228    pub version: Option<String>,
229    /// Optional bump strategy.
230    pub bump: Option<String>,
231    /// Skip changelog generation.
232    pub no_changelog: bool,
233    /// Skip commit creation.
234    pub no_commit: bool,
235    /// Skip tag creation.
236    pub no_tag: bool,
237    /// Commit message template.
238    pub commit_template: Option<String>,
239    /// Tag template.
240    pub tag_template: Option<String>,
241    /// Dry run.
242    pub dry_run: bool,
243    /// Allow workspace version drift from the last release tag.
244    pub allow_version_drift: bool,
245    /// Allow mutating release steps on a dirty working tree.
246    pub allow_dirty: bool,
247}
248
249/// Bump output.
250#[derive(Debug, Clone, Serialize)]
251pub struct BumpOutput {
252    /// Workspace name.
253    pub workspace: String,
254    /// Previous version.
255    pub previous_version: String,
256    /// New version.
257    pub new_version: String,
258    /// Whether any files would change.
259    pub changed: bool,
260    /// Modified files.
261    pub files_modified: Vec<String>,
262    /// Commit hash if created.
263    pub commit_hash: Option<String>,
264    /// Whether a tag was created.
265    pub tag_created: bool,
266    /// Dry-run flag.
267    pub dry_run: bool,
268}
269
270/// Publish input.
271#[derive(Debug, Clone)]
272pub struct PublishInput {
273    /// Workspace root.
274    pub workspace_path: String,
275    /// Checks to skip.
276    pub skip_checks: Option<String>,
277    /// Only publish these crates.
278    pub only: Option<String>,
279    /// Exclude crates.
280    pub exclude: Option<String>,
281    /// Delay between publishes.
282    pub delay: Option<u64>,
283    /// Max retries per crate.
284    pub max_retries: Option<usize>,
285    /// Error handling strategy.
286    pub on_error: Option<String>,
287    /// Dry run.
288    pub dry_run: bool,
289    /// Allow workspace version drift from the last release tag.
290    pub allow_version_drift: bool,
291    /// Allow mutating release steps on a dirty working tree.
292    pub allow_dirty: bool,
293}
294
295/// Published crate entry.
296#[derive(Debug, Clone, Serialize)]
297pub struct PublishedCrate {
298    /// Crate name.
299    pub name: String,
300    /// Crate version.
301    pub version: String,
302    /// Publish time.
303    pub publish_time_ms: u64,
304    /// crates.io URL.
305    pub crates_io_url: String,
306}
307
308/// Publish output.
309#[derive(Debug, Clone, Serialize)]
310pub struct PublishOutput {
311    /// Workspace name.
312    pub workspace: String,
313    /// Published crates.
314    pub crates_published: Vec<PublishedCrate>,
315    /// Skipped crates.
316    pub crates_skipped: Vec<SkippedCrate>,
317    /// Failed crates.
318    pub crates_failed: Vec<(String, String, String)>,
319    /// Dry run flag.
320    pub dry_run: bool,
321    /// Checks run before publish.
322    pub checks: Vec<CheckItem>,
323}
324
325/// Full release input.
326#[derive(Debug, Clone)]
327pub struct FullInput {
328    /// Workspace root.
329    pub workspace_path: String,
330    /// Inner bump options.
331    pub bump: BumpInput,
332    /// Inner publish options.
333    pub publish: PublishInput,
334    /// Skip pre-publish checks.
335    pub skip_checks: bool,
336    /// Skip push after publish.
337    pub no_push: bool,
338}
339
340/// Full release output.
341#[derive(Debug, Clone, Serialize)]
342pub struct FullOutput {
343    /// Workspace name.
344    pub workspace: String,
345    /// Completed steps.
346    pub steps_completed: Vec<String>,
347    /// Final version.
348    pub version: String,
349    /// Publish summary.
350    pub publish: PublishOutput,
351    /// Dry run flag.
352    pub dry_run: bool,
353}
354
355/// Simulate input.
356#[derive(Debug, Clone)]
357pub struct SimulateInput {
358    /// Workspace root.
359    pub workspace_path: String,
360    /// Optional target version.
361    pub version: Option<String>,
362    /// Synthetic downstream count.
363    pub downstream_crates: Option<usize>,
364}
365
366/// Simulate output.
367#[derive(Debug, Clone, Serialize)]
368pub struct SimulateOutput {
369    /// Workspace name.
370    pub workspace: String,
371    /// Version diff.
372    pub version_diff: HashMap<String, String>,
373    /// Breaking changes.
374    pub breaking_changes: usize,
375    /// Feature count.
376    pub features: usize,
377    /// Fix count.
378    pub fixes: usize,
379    /// Other commit count.
380    pub other: usize,
381    /// Recommendations.
382    pub recommendations: Vec<String>,
383}
384
385/// Resume input.
386#[derive(Debug, Clone)]
387pub struct ResumeInput {
388    /// Workspace root.
389    pub workspace_path: String,
390    /// Checkpoint id.
391    pub checkpoint: Option<String>,
392    /// List checkpoints.
393    pub list: bool,
394    /// Clear checkpoints.
395    pub clean: bool,
396}
397
398/// Resume output.
399#[derive(Debug, Clone, Serialize)]
400pub struct ResumeOutput {
401    /// Workspace display.
402    pub workspace: String,
403    /// Loaded checkpoints.
404    pub checkpoints: Vec<Checkpoint>,
405    /// Whether checkpoints were cleared.
406    pub cleared: bool,
407}
408
409/// Risk assessment item.
410#[derive(Debug, Clone, Serialize)]
411pub struct RiskFactor {
412    /// Name of the factor.
413    pub name: String,
414    /// Weight.
415    pub weight: f64,
416    /// Why it matters.
417    pub reason: String,
418}
419
420/// Risk assessment.
421#[derive(Debug, Clone, Serialize)]
422pub struct RiskAssessment {
423    /// Aggregate score.
424    pub score: f64,
425    /// Coarse level.
426    pub level: String,
427    /// Individual factors.
428    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
806/// Execute release analyze.
807///
808/// # Errors
809///
810/// Returns an error when workspace metadata or source control history
811/// cannot be loaded, or when a bump override is invalid.
812pub 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
863/// Execute release plan.
864///
865/// # Errors
866///
867/// Returns an error when the workspace, source control state, or registry
868/// publication status cannot be loaded.
869pub 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
945/// Execute release status.
946///
947/// # Errors
948///
949/// Returns an error when workspace metadata or source control state cannot
950/// be loaded.
951pub 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
1012/// Execute pre-publish checks.
1013///
1014/// # Errors
1015///
1016/// Returns an error when a configured check cannot be started or its
1017/// report cannot be collected.
1018pub 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
1063/// Execute version bump.
1064///
1065/// # Errors
1066///
1067/// Returns an error when workspace metadata cannot be loaded, the target
1068/// version cannot be derived, or workspace mutations fail.
1069pub 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
1365/// Execute publish.
1366///
1367/// # Errors
1368///
1369/// Returns an error when workspace metadata cannot be loaded, pre-publish
1370/// checks cannot be run, or registry state cannot be queried.
1371pub 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
1423/// Execute full release workflow.
1424///
1425/// # Errors
1426///
1427/// Returns an error when any mandatory step cannot be started or when the
1428/// source control push fails.
1429pub 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
1491/// Execute release simulation.
1492///
1493/// # Errors
1494///
1495/// Returns an error when workspace metadata or source control history
1496/// cannot be loaded.
1497pub 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
1554/// Execute checkpoint resume management.
1555///
1556/// # Errors
1557///
1558/// Returns an error when checkpoint state cannot be listed, loaded, or
1559/// cleared.
1560pub 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}