Skip to main content

batuta/stack/releaser/
orchestrator.rs

1//! Release Orchestrator
2//!
3//! Implements the `batuta stack release` command functionality.
4//! Coordinates releases across multiple crates in topological order,
5//! ensuring all quality gates pass before publishing.
6
7use crate::stack::checker::StackChecker;
8use crate::stack::types::*;
9use anyhow::{anyhow, Result};
10use std::collections::HashMap;
11use std::path::Path;
12
13// Re-export types from releaser_types module
14pub use super::super::releaser_types::{
15    format_plan_text, BumpType, ReleaseConfig, ReleaseResult, ReleasedCrate,
16};
17
18/// Release orchestrator for coordinated multi-crate releases
19pub struct ReleaseOrchestrator {
20    /// Release configuration (pub(super) for preflight module access)
21    pub(in crate::stack) config: ReleaseConfig,
22
23    /// Stack checker for health analysis
24    checker: StackChecker,
25
26    /// Pre-flight results per crate
27    preflight_results: HashMap<String, PreflightResult>,
28}
29
30impl ReleaseOrchestrator {
31    /// Create a new release orchestrator
32    pub fn new(checker: StackChecker, config: ReleaseConfig) -> Self {
33        Self { config, checker, preflight_results: HashMap::new() }
34    }
35
36    /// Create a release orchestrator from a workspace path
37    #[cfg(feature = "native")]
38    pub fn from_workspace(workspace_path: &Path, config: ReleaseConfig) -> Result<Self> {
39        let checker = StackChecker::from_workspace(workspace_path)?;
40        Ok(Self::new(checker, config))
41    }
42
43    /// Plan a release for a specific crate
44    pub fn plan_release(&mut self, crate_name: &str) -> Result<ReleasePlan> {
45        let release_order = self.checker.release_order_for(crate_name)?;
46
47        let mut releases = Vec::new();
48
49        for name in &release_order {
50            let planned = self.plan_single_release(name)?;
51            releases.push(planned);
52        }
53
54        Ok(ReleasePlan {
55            releases,
56            dry_run: self.config.dry_run,
57            preflight_results: self.preflight_results.clone(),
58        })
59    }
60
61    /// Plan a release for all crates with changes
62    pub fn plan_all_releases(&mut self) -> Result<ReleasePlan> {
63        let release_order = self.checker.topological_order()?;
64
65        let mut releases = Vec::new();
66
67        for name in &release_order {
68            let planned = self.plan_single_release(name)?;
69            releases.push(planned);
70        }
71
72        Ok(ReleasePlan {
73            releases,
74            dry_run: self.config.dry_run,
75            preflight_results: self.preflight_results.clone(),
76        })
77    }
78
79    /// Plan a single crate release
80    fn plan_single_release(&self, crate_name: &str) -> Result<PlannedRelease> {
81        let current_version = self
82            .checker
83            .get_crate(crate_name)
84            .map(|c| c.local_version.clone())
85            .unwrap_or_else(|| semver::Version::new(0, 0, 0));
86
87        let new_version = match self.config.bump_type {
88            Some(bump) => bump.apply(&current_version),
89            None => semver::Version::new(
90                current_version.major,
91                current_version.minor,
92                current_version.patch + 1,
93            ),
94        };
95
96        let ready = self.preflight_results.get(crate_name).map(|r| r.passed).unwrap_or(true);
97
98        Ok(PlannedRelease {
99            crate_name: crate_name.to_string(),
100            current_version,
101            new_version,
102            dependents: vec![],
103            ready,
104        })
105    }
106
107    /// Run pre-flight checks for a crate
108    pub fn run_preflight(
109        &mut self,
110        crate_name: &str,
111        crate_path: &Path,
112    ) -> Result<PreflightResult> {
113        let mut result = PreflightResult::new(crate_name);
114
115        if self.config.no_verify {
116            result.add_check(PreflightCheck::pass("verification", "Skipped (--no-verify)"));
117            self.preflight_results.insert(crate_name.to_string(), result.clone());
118            return Ok(result);
119        }
120
121        // Check 1: Git clean
122        let git_check = self.check_git_clean(crate_path);
123        result.add_check(git_check);
124
125        // Check 2: Lint
126        let lint_check = self.check_lint(crate_path);
127        result.add_check(lint_check);
128
129        // Check 3: Coverage
130        let coverage_check = self.check_coverage(crate_path);
131        result.add_check(coverage_check);
132
133        // Check 4: PMAT comply (ComputeBrick defect detection)
134        let comply_check = self.check_pmat_comply(crate_path);
135        result.add_check(comply_check);
136
137        // Check 5: No path dependencies
138        let path_check = self.check_no_path_deps(crate_name);
139        result.add_check(path_check);
140
141        // Check 6: Version bumped
142        let version_check = self.check_version_bumped(crate_name);
143        result.add_check(version_check);
144
145        // =====================================================================
146        // PMAT Quality Gate Integration (PMAT-STACK-GATES)
147        // =====================================================================
148
149        // Check 7: PMAT quality-gate (comprehensive checks)
150        let quality_gate_check = self.check_pmat_quality_gate(crate_path);
151        result.add_check(quality_gate_check);
152
153        // Check 8: PMAT TDG (Technical Debt Grading)
154        let tdg_check = self.check_pmat_tdg(crate_path);
155        result.add_check(tdg_check);
156
157        // Check 9: PMAT dead-code analysis
158        let dead_code_check = self.check_pmat_dead_code(crate_path);
159        result.add_check(dead_code_check);
160
161        // Check 10: PMAT complexity analysis
162        let complexity_check = self.check_pmat_complexity(crate_path);
163        result.add_check(complexity_check);
164
165        // Check 11: PMAT SATD (Self-Admitted Technical Debt)
166        let satd_check = self.check_pmat_satd(crate_path);
167        result.add_check(satd_check);
168
169        // Check 12: PMAT Popper score (falsifiability)
170        let popper_check = self.check_pmat_popper(crate_path);
171        result.add_check(popper_check);
172
173        // =====================================================================
174        // Book and Examples Verification (RELEASE-DOCS)
175        // =====================================================================
176
177        // Check 13: Book build
178        let book_check = self.check_book_build(crate_path);
179        result.add_check(book_check);
180
181        // Check 14: Examples verification
182        let examples_check = self.check_examples_run(crate_path);
183        result.add_check(examples_check);
184
185        self.preflight_results.insert(crate_name.to_string(), result.clone());
186        Ok(result)
187    }
188
189    // Note: check_* methods are in releaser_preflight.rs module
190
191    /// Execute the release plan
192    #[cfg(feature = "native")]
193    pub fn execute(&self, plan: &ReleasePlan) -> Result<ReleaseResult> {
194        if plan.dry_run {
195            return Ok(ReleaseResult {
196                success: true,
197                released_crates: vec![],
198                message: "Dry run - no changes made".to_string(),
199            });
200        }
201
202        let mut released = Vec::new();
203
204        for release in &plan.releases {
205            // Check preflight passed
206            if let Some(preflight) = plan.preflight_results.get(&release.crate_name) {
207                if !preflight.passed {
208                    return Err(anyhow!(
209                        "Pre-flight checks failed for {}: cannot release",
210                        release.crate_name
211                    ));
212                }
213            }
214
215            // Get manifest path for version bump (only execute file ops when path exists)
216            let manifest_path = self
217                .checker
218                .get_crate(&release.crate_name)
219                .map(|c| c.manifest_path.clone())
220                .filter(|p| p.exists());
221
222            // Update Cargo.toml version (only if file exists)
223            if let Some(ref path) = manifest_path {
224                self.update_cargo_toml(path, &release.new_version)?;
225
226                // Create git tag after version bump
227                self.create_git_tag(&release.crate_name, &release.new_version)?;
228            }
229
230            if self.config.publish {
231                if let Some(ref path) = manifest_path {
232                    let crate_dir = path.parent().unwrap_or(Path::new("."));
233                    self.cargo_publish(crate_dir)?;
234                }
235            }
236
237            released.push(ReleasedCrate {
238                name: release.crate_name.clone(),
239                version: release.new_version.clone(),
240                published: self.config.publish && manifest_path.is_some(),
241            });
242        }
243
244        Ok(ReleaseResult {
245            success: true,
246            released_crates: released,
247            message: format!("Successfully released {} crates", plan.releases.len()),
248        })
249    }
250
251    /// Update version in Cargo.toml
252    #[cfg(feature = "native")]
253    pub(crate) fn update_cargo_toml(
254        &self,
255        manifest_path: &Path,
256        new_version: &semver::Version,
257    ) -> Result<()> {
258        let content = std::fs::read_to_string(manifest_path)
259            .map_err(|e| anyhow!("Failed to read {}: {}", manifest_path.display(), e))?;
260
261        let version_str = new_version.to_string();
262
263        // Replace version in [package] section using line-by-line rewrite
264        // This preserves formatting better than full TOML parse/serialize
265        let mut output = String::with_capacity(content.len());
266        let mut in_package = false;
267        let mut version_replaced = false;
268
269        for line in content.lines() {
270            let trimmed = line.trim();
271            if trimmed == "[package]" {
272                in_package = true;
273            } else if trimmed.starts_with('[') {
274                in_package = false;
275            }
276
277            if in_package && !version_replaced && trimmed.starts_with("version") {
278                if let Some(eq_pos) = line.find('=') {
279                    let prefix = &line[..=eq_pos];
280                    output.push_str(&format!("{} \"{}\"", prefix, version_str));
281                    output.push('\n');
282                    version_replaced = true;
283                    continue;
284                }
285            }
286
287            output.push_str(line);
288            output.push('\n');
289        }
290
291        if !version_replaced {
292            return Err(anyhow!(
293                "Could not find version field in [package] section of {}",
294                manifest_path.display()
295            ));
296        }
297
298        std::fs::write(manifest_path, output)
299            .map_err(|e| anyhow!("Failed to write {}: {}", manifest_path.display(), e))?;
300
301        Ok(())
302    }
303
304    /// Create a git tag for the release
305    #[cfg(feature = "native")]
306    fn create_git_tag(&self, crate_name: &str, version: &semver::Version) -> Result<()> {
307        let tag = format!("{}-v{}", crate_name, version);
308        let message = format!("Release {} v{}", crate_name, version);
309
310        let output = std::process::Command::new("git")
311            .args(["tag", "-a", &tag, "-m", &message])
312            .output()
313            .map_err(|e| anyhow!("Failed to create git tag: {}", e))?;
314
315        if !output.status.success() {
316            let stderr = String::from_utf8_lossy(&output.stderr);
317            return Err(anyhow!("Git tag failed: {}", stderr));
318        }
319
320        Ok(())
321    }
322
323    /// Publish crate to crates.io
324    #[cfg(feature = "native")]
325    fn cargo_publish(&self, crate_dir: &Path) -> Result<()> {
326        let mut cmd = std::process::Command::new("cargo");
327        cmd.arg("publish").current_dir(crate_dir);
328
329        if self.config.dry_run {
330            cmd.arg("--dry-run");
331        }
332
333        let output = cmd.output().map_err(|e| anyhow!("Failed to run cargo publish: {}", e))?;
334
335        if !output.status.success() {
336            let stderr = String::from_utf8_lossy(&output.stderr);
337            return Err(anyhow!("cargo publish failed: {}", stderr));
338        }
339
340        Ok(())
341    }
342}