batuta/stack/releaser/
orchestrator.rs1use crate::stack::checker::StackChecker;
8use crate::stack::types::*;
9use anyhow::{anyhow, Result};
10use std::collections::HashMap;
11use std::path::Path;
12
13pub use super::super::releaser_types::{
15 format_plan_text, BumpType, ReleaseConfig, ReleaseResult, ReleasedCrate,
16};
17
18pub struct ReleaseOrchestrator {
20 pub(in crate::stack) config: ReleaseConfig,
22
23 checker: StackChecker,
25
26 preflight_results: HashMap<String, PreflightResult>,
28}
29
30impl ReleaseOrchestrator {
31 pub fn new(checker: StackChecker, config: ReleaseConfig) -> Self {
33 Self { config, checker, preflight_results: HashMap::new() }
34 }
35
36 #[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 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 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 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(¤t_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 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 let git_check = self.check_git_clean(crate_path);
123 result.add_check(git_check);
124
125 let lint_check = self.check_lint(crate_path);
127 result.add_check(lint_check);
128
129 let coverage_check = self.check_coverage(crate_path);
131 result.add_check(coverage_check);
132
133 let comply_check = self.check_pmat_comply(crate_path);
135 result.add_check(comply_check);
136
137 let path_check = self.check_no_path_deps(crate_name);
139 result.add_check(path_check);
140
141 let version_check = self.check_version_bumped(crate_name);
143 result.add_check(version_check);
144
145 let quality_gate_check = self.check_pmat_quality_gate(crate_path);
151 result.add_check(quality_gate_check);
152
153 let tdg_check = self.check_pmat_tdg(crate_path);
155 result.add_check(tdg_check);
156
157 let dead_code_check = self.check_pmat_dead_code(crate_path);
159 result.add_check(dead_code_check);
160
161 let complexity_check = self.check_pmat_complexity(crate_path);
163 result.add_check(complexity_check);
164
165 let satd_check = self.check_pmat_satd(crate_path);
167 result.add_check(satd_check);
168
169 let popper_check = self.check_pmat_popper(crate_path);
171 result.add_check(popper_check);
172
173 let book_check = self.check_book_build(crate_path);
179 result.add_check(book_check);
180
181 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 #[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 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 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 if let Some(ref path) = manifest_path {
224 self.update_cargo_toml(path, &release.new_version)?;
225
226 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 #[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 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 #[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 #[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}