1use std::collections::BTreeMap;
62use std::path::{Path, PathBuf};
63
64use crate::contract::schema::{ChangelogMode, ChangelogSource};
65use crate::protocol::plan::{BumpPlan, ChangelogFinalizePlan};
66use crate::release::adapters::EffectCtx;
67use crate::release::bump::{self, BumpEditError};
68
69#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BumpOutcome {
73 pub commit: String,
75 pub effective_date: String,
77}
78
79#[derive(Debug)]
82pub enum BumpExecError {
83 Edit(BumpEditError),
86 Fs {
88 path: PathBuf,
90 source: std::io::Error,
92 },
93 MemberManifestNotFound {
97 package: String,
99 },
100 LockRefresh(String),
102 ChangelogCompile(String),
104 Hook {
106 status: String,
108 stderr: String,
110 },
111 HookViolatedVersion {
114 expected: String,
116 found: String,
118 },
119 Git(String),
121}
122
123impl std::fmt::Display for BumpExecError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Self::Edit(e) => write!(f, "{e}"),
127 Self::Fs { path, source } => {
128 write!(
129 f,
130 "cannot access `{}` in the sealed checkout: {source}",
131 path.display()
132 )
133 }
134 Self::MemberManifestNotFound { package } => write!(
135 f,
136 "the bump names crate `{package}` but its manifest could not be located in the \
137 sealed checkout"
138 ),
139 Self::LockRefresh(m) => write!(f, "refreshing Cargo.lock failed: {m}"),
140 Self::ChangelogCompile(m) => write!(f, "compiling changelog notes failed: {m}"),
141 Self::Hook { status, stderr } => {
142 write!(f, "the bump_hook failed ({status}): {stderr}")
143 }
144 Self::HookViolatedVersion { expected, found } => write!(
145 f,
146 "the bump_hook changed the root manifest version to `{found}`, but the bump set \
147 `{expected}` — refusing to publish a hook-altered version"
148 ),
149 Self::Git(m) => write!(f, "git step failed during the bump: {m}"),
150 }
151 }
152}
153
154impl std::error::Error for BumpExecError {}
155
156impl From<BumpEditError> for BumpExecError {
157 fn from(e: BumpEditError) -> Self {
158 Self::Edit(e)
159 }
160}
161
162pub fn apply_bump(
178 ctx: &EffectCtx<'_>,
179 bump: &BumpPlan,
180 effective_date: &str,
181) -> Result<BumpOutcome, BumpExecError> {
182 let root = ctx.repo_root;
183
184 let root_manifest = root.join("Cargo.toml");
188 let text = read(&root_manifest)?;
189 let bumped = if bump::workspace_version(&text).is_some() {
190 bump::set_workspace_version(&text, &bump.from_version, &bump.to_version)?
191 } else {
192 bump::set_package_version(&text, &bump.from_version, &bump.to_version)?
193 };
194 write(&root_manifest, &bumped)?;
195
196 if !bump.pin_rewrites.is_empty() {
200 let members = member_manifest_paths(root)?;
201 for pin in &bump.pin_rewrites {
202 let manifest = if pin.workspace_root {
203 &root_manifest
204 } else {
205 members.get(&pin.in_package).ok_or_else(|| {
206 BumpExecError::MemberManifestNotFound {
207 package: pin.in_package.clone(),
208 }
209 })?
210 };
211 let text = read(manifest)?;
212 let rewritten = if pin.workspace_root {
213 bump::rewrite_workspace_pin(&text, &pin.dependency, &pin.from, &pin.to)?
214 } else {
215 bump::rewrite_pin(&text, &pin.dependency, &pin.from, &pin.to)?
216 };
217 write(manifest, &rewritten)?;
218 }
219 }
220
221 if root.join("Cargo.lock").exists() {
228 refresh_lockfile(ctx)?;
229 }
230
231 if bump.changelog_finalize {
235 let changelog = root.join("CHANGELOG.md");
236 if changelog.is_file() {
237 let text = read(&changelog)?;
238 let (finalized, consumed) = if let Some(plan) = &bump.changelog {
239 let (compiled, consumed) = compile_changelog(ctx, plan)?;
240 (
241 bump::finalize_marker_changelog(
242 &text,
243 &bump.to_version,
244 effective_date,
245 &compiled,
246 )?,
247 consumed,
248 )
249 } else {
250 (
253 bump::finalize_changelog(&text, &bump.to_version, effective_date)?,
254 Vec::new(),
255 )
256 };
257 write(&changelog, &finalized)?;
258 for fragment in consumed {
259 std::fs::remove_file(&fragment).map_err(|source| BumpExecError::Fs {
260 path: fragment,
261 source,
262 })?;
263 }
264 } else if bump.changelog.is_some() {
265 return Err(BumpEditError::ChangelogUnreleasedNotFound.into());
266 }
267 }
268
269 if let Some(hook) = &bump.bump_hook {
272 run_hook(ctx, hook)?;
273 let after = read(&root_manifest)?;
274 let found = bump::root_manifest_version(&after);
275 if found.as_deref() != Some(bump.to_version.as_str()) {
276 return Err(BumpExecError::HookViolatedVersion {
277 expected: bump.to_version.clone(),
278 found: found.unwrap_or_default(),
279 });
280 }
281 }
282
283 let commit = commit_bump(ctx, &bump.to_version)?;
285
286 Ok(BumpOutcome {
287 commit,
288 effective_date: effective_date.to_string(),
289 })
290}
291
292fn compile_changelog(
296 ctx: &EffectCtx<'_>,
297 plan: &ChangelogFinalizePlan,
298) -> Result<(String, Vec<PathBuf>), BumpExecError> {
299 let mut sources = Vec::new();
300 let mut consumed = Vec::new();
301
302 match plan.mode {
303 ChangelogMode::Fragment => {
304 collect_fragments(ctx.repo_root, plan, &mut sources, &mut consumed)?;
305 }
306 ChangelogMode::Curated => {}
307 ChangelogMode::Automated => {
308 return Err(BumpExecError::ChangelogCompile(
309 "an automated changelog cannot carry engine finalization intent".into(),
310 ));
311 }
312 }
313
314 match plan.source {
315 ChangelogSource::IssuectlTrailers => {
316 let range = plan.issuectl_range.as_deref().ok_or_else(|| {
317 BumpExecError::ChangelogCompile(
318 "the sealed changelog plan has no issuectl revision range".into(),
319 )
320 })?;
321 let root = ctx.repo_root.to_string_lossy();
322 if let Ok(output) = ctx.runner.run(
323 "issuectl",
324 &["changelog", range, "--json", "--root", &root],
325 ctx.repo_root,
326 ) {
327 if output.status == Some(0) {
328 if let Ok(notes) = render_issuectl_notes(&output.stdout) {
329 if !notes.is_empty() {
330 sources.push(notes);
331 }
332 }
333 }
334 }
335 }
338 ChangelogSource::Manual | ChangelogSource::ConventionalCommits => {}
339 }
340
341 Ok((
342 sources
343 .into_iter()
344 .map(|source| source.trim().to_string())
345 .filter(|source| !source.is_empty())
346 .collect::<Vec<_>>()
347 .join("\n\n"),
348 consumed,
349 ))
350}
351
352fn collect_fragments(
353 root: &Path,
354 plan: &ChangelogFinalizePlan,
355 sources: &mut Vec<String>,
356 consumed: &mut Vec<PathBuf>,
357) -> Result<(), BumpExecError> {
358 let dir = root.join(&plan.fragment_dir);
359 if !dir.exists() {
360 return Ok(());
361 }
362 let metadata = std::fs::symlink_metadata(&dir).map_err(|source| BumpExecError::Fs {
363 path: dir.clone(),
364 source,
365 })?;
366 if metadata.file_type().is_symlink() || !metadata.is_dir() {
367 return Err(BumpExecError::ChangelogCompile(format!(
368 "fragment directory `{}` must be a real directory inside the checkout",
369 plan.fragment_dir
370 )));
371 }
372 let canonical_root = std::fs::canonicalize(root).map_err(|source| BumpExecError::Fs {
373 path: root.to_path_buf(),
374 source,
375 })?;
376 let canonical_dir = std::fs::canonicalize(&dir).map_err(|source| BumpExecError::Fs {
377 path: dir.clone(),
378 source,
379 })?;
380 if !canonical_dir.starts_with(&canonical_root) {
381 return Err(BumpExecError::ChangelogCompile(format!(
382 "fragment directory `{}` resolves outside the checkout",
383 plan.fragment_dir
384 )));
385 }
386 let entries = std::fs::read_dir(&dir).map_err(|source| BumpExecError::Fs {
387 path: dir.clone(),
388 source,
389 })?;
390 let mut paths = entries
391 .map(|entry| {
392 entry
393 .map(|entry| entry.path())
394 .map_err(|source| BumpExecError::Fs {
395 path: dir.clone(),
396 source,
397 })
398 })
399 .collect::<Result<Vec<_>, _>>()?;
400 paths.sort();
401 for path in paths {
402 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
403 continue;
404 };
405 if name.starts_with('.')
406 || name.eq_ignore_ascii_case("README.md")
407 || !path
408 .extension()
409 .and_then(|extension| extension.to_str())
410 .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
411 {
412 continue;
413 }
414 let metadata = std::fs::symlink_metadata(&path).map_err(|source| BumpExecError::Fs {
415 path: path.clone(),
416 source,
417 })?;
418 if !metadata.file_type().is_file() {
419 continue;
420 }
421 let contents = read(&path)?;
422 if !contents.trim().is_empty() {
423 sources.push(contents);
424 consumed.push(path);
425 }
426 }
427 Ok(())
428}
429
430fn render_issuectl_notes(json: &str) -> Result<String, BumpExecError> {
432 let value: serde_json::Value = serde_json::from_str(json)
433 .map_err(|e| BumpExecError::ChangelogCompile(format!("invalid issuectl JSON: {e}")))?;
434 if value
435 .get("schema_version")
436 .and_then(serde_json::Value::as_u64)
437 != Some(1)
438 {
439 return Err(BumpExecError::ChangelogCompile(
440 "issuectl JSON has an unsupported schema_version".into(),
441 ));
442 }
443 let groups = value
444 .get("data")
445 .and_then(|data| data.get("groups"))
446 .and_then(serde_json::Value::as_object)
447 .ok_or_else(|| {
448 BumpExecError::ChangelogCompile("issuectl JSON has no data.groups object".into())
449 })?;
450 let mut categories: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
451 for (kind, issues) in groups {
452 let heading = match kind.as_str() {
453 "feature" => "Added",
454 "bug" => "Fixed",
455 _ => "Changed",
456 };
457 let issues = issues.as_array().ok_or_else(|| {
458 BumpExecError::ChangelogCompile(format!("issuectl group `{kind}` is not an array"))
459 })?;
460 for issue in issues {
461 let title = issue
462 .get("title")
463 .and_then(serde_json::Value::as_str)
464 .ok_or_else(|| {
465 BumpExecError::ChangelogCompile(format!(
466 "issuectl group `{kind}` has an item without title"
467 ))
468 })?;
469 let slug = issue
470 .get("slug")
471 .and_then(serde_json::Value::as_str)
472 .ok_or_else(|| {
473 BumpExecError::ChangelogCompile(format!(
474 "issuectl group `{kind}` has an item without slug"
475 ))
476 })?;
477 categories
478 .entry(heading)
479 .or_default()
480 .push(format!("- {title} (`{slug}`)."));
481 }
482 }
483 let mut rendered = Vec::new();
484 for (heading, mut bullets) in categories {
485 bullets.sort();
486 bullets.dedup();
487 if !bullets.is_empty() {
488 rendered.push(format!("### {heading}\n\n{}", bullets.join("\n")));
489 }
490 }
491 Ok(rendered.join("\n\n"))
492}
493
494fn read(path: &Path) -> Result<String, BumpExecError> {
496 std::fs::read_to_string(path).map_err(|source| BumpExecError::Fs {
497 path: path.to_path_buf(),
498 source,
499 })
500}
501
502fn write(path: &Path, contents: &str) -> Result<(), BumpExecError> {
504 std::fs::write(path, contents).map_err(|source| BumpExecError::Fs {
505 path: path.to_path_buf(),
506 source,
507 })
508}
509
510fn refresh_lockfile(ctx: &EffectCtx<'_>) -> Result<(), BumpExecError> {
513 let out = ctx
514 .runner
515 .run("cargo", &["update", "--workspace"], ctx.repo_root)
516 .map_err(|e| BumpExecError::LockRefresh(format!("cannot run cargo: {e}")))?;
517 if out.status != Some(0) {
518 return Err(BumpExecError::LockRefresh(format!(
519 "exit {}: {}",
520 status_str(out.status),
521 out.stderr.trim()
522 )));
523 }
524 Ok(())
525}
526
527fn run_hook(ctx: &EffectCtx<'_>, hook: &str) -> Result<(), BumpExecError> {
530 let out = ctx
531 .runner
532 .run("sh", &["-c", hook], ctx.repo_root)
533 .map_err(|e| BumpExecError::Hook {
534 status: "spawn failed".to_string(),
535 stderr: e.to_string(),
536 })?;
537 if out.status != Some(0) {
538 return Err(BumpExecError::Hook {
539 status: status_str(out.status),
540 stderr: out.stderr.trim().to_string(),
541 });
542 }
543 Ok(())
544}
545
546fn commit_bump(ctx: &EffectCtx<'_>, version: &str) -> Result<String, BumpExecError> {
549 let root = ctx.repo_root;
550 run_git(ctx, &["add", "-A"], root)?;
551 let message = format!("release: v{version}");
552 run_git(ctx, &["commit", "-m", &message], root)?;
553 let out = ctx
554 .runner
555 .run("git", &["rev-parse", "HEAD"], root)
556 .map_err(|e| BumpExecError::Git(format!("rev-parse HEAD: {e}")))?;
557 if out.status != Some(0) {
558 return Err(BumpExecError::Git(format!(
559 "rev-parse HEAD exit {}: {}",
560 status_str(out.status),
561 out.stderr.trim()
562 )));
563 }
564 let sha = out.stdout.trim().to_string();
565 if sha.is_empty() {
566 return Err(BumpExecError::Git(
567 "git rev-parse HEAD returned no commit sha after the bump commit".to_string(),
568 ));
569 }
570 Ok(sha)
571}
572
573fn run_git(ctx: &EffectCtx<'_>, args: &[&str], cwd: &Path) -> Result<(), BumpExecError> {
575 let out = ctx
576 .runner
577 .run("git", args, cwd)
578 .map_err(|e| BumpExecError::Git(format!("`git {}`: {e}", args.join(" "))))?;
579 if out.status != Some(0) {
580 return Err(BumpExecError::Git(format!(
581 "`git {}` exit {}: {}",
582 args.join(" "),
583 status_str(out.status),
584 out.stderr.trim()
585 )));
586 }
587 Ok(())
588}
589
590fn member_manifest_paths(root: &Path) -> Result<BTreeMap<String, PathBuf>, BumpExecError> {
595 let root_manifest = root.join("Cargo.toml");
596 let text = read(&root_manifest)?;
597 let mut map = BTreeMap::new();
598 for rel in workspace_member_dirs(root, &text) {
599 let manifest = root.join(&rel).join("Cargo.toml");
600 let Ok(member_text) = std::fs::read_to_string(&manifest) else {
601 continue;
602 };
603 if let Some(name) = package_name(&member_text) {
604 map.insert(name, manifest);
605 }
606 }
607 Ok(map)
608}
609
610fn workspace_member_dirs(root: &Path, root_text: &str) -> Vec<String> {
614 let Some(members) = toml_string_array(root_text, "members") else {
615 return Vec::new();
616 };
617 let mut dirs = Vec::new();
618 for entry in members {
619 if let Some(parent) = entry.strip_suffix("/*") {
620 if let Ok(read_dir) = std::fs::read_dir(root.join(parent)) {
622 for e in read_dir.flatten() {
623 if e.path().is_dir() {
624 dirs.push(format!("{parent}/{}", e.file_name().to_string_lossy()));
625 }
626 }
627 }
628 } else if !entry.contains('*') {
629 dirs.push(entry);
630 }
631 }
632 dirs
633}
634
635fn toml_string_array(text: &str, key: &str) -> Option<Vec<String>> {
638 let mut in_workspace = false;
640 let mut collecting = false;
641 let mut buf = String::new();
642 for line in text.lines() {
643 let t = line.trim();
644 if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
645 in_workspace = h.trim() == "workspace";
646 continue;
647 }
648 if collecting {
649 buf.push_str(line);
650 if line.contains(']') {
651 break;
652 }
653 continue;
654 }
655 if in_workspace {
656 if let Some(rest) = strip_key(t, key) {
657 if let Some(after) = rest.trim_start().strip_prefix('[') {
658 buf.push_str(after);
659 if t.contains(']') {
660 break;
661 }
662 collecting = true;
663 }
664 }
665 }
666 }
667 if buf.is_empty() && !collecting {
668 return None;
669 }
670 let inner = buf.split(']').next().unwrap_or("");
671 let items: Vec<String> = inner
672 .split(',')
673 .filter_map(|s| {
674 let s = s.trim().trim_matches(['"', '\'']);
675 (!s.is_empty()).then(|| s.to_string())
676 })
677 .collect();
678 Some(items)
679}
680
681fn strip_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
683 let rest = line.strip_prefix(key)?;
684 let rest = rest.trim_start();
685 rest.strip_prefix('=')
686}
687
688fn package_name(text: &str) -> Option<String> {
690 let mut in_package = false;
691 for line in text.lines() {
692 let t = line.trim();
693 if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
694 in_package = h.trim() == "package";
695 continue;
696 }
697 if in_package {
698 if let Some(rest) = strip_key(t, "name") {
699 return Some(rest.trim().trim_matches(['"', '\'']).to_string());
700 }
701 }
702 }
703 None
704}
705
706fn status_str(status: Option<i32>) -> String {
708 status.map_or_else(|| "signal".to_string(), |c| c.to_string())
709}
710
711#[must_use]
717pub fn civil_date(unix_secs: u64) -> String {
718 let days = i64::try_from(unix_secs / 86_400).unwrap_or(i64::MAX);
719 let z = days + 719_468;
721 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
722 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
725 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
730 format!("{y:04}-{m:02}-{d:02}")
731}
732
733#[cfg(test)]
734mod tests;