Skip to main content

batty_cli/
release.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8
9use crate::team::config::TeamConfig;
10use crate::team::daemon::verification::run_automatic_verification;
11use crate::team::events::{EventSink, TeamEvent};
12
13const RELEASES_DIR: &str = ".batty/releases";
14const RELEASE_HISTORY_FILE: &str = "history.jsonl";
15const RELEASE_LATEST_JSON: &str = "latest.json";
16const RELEASE_LATEST_MARKDOWN: &str = "latest.md";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19struct ReleaseMetadata {
20    package_name: String,
21    version: String,
22    tag: String,
23    changelog_heading: String,
24    changelog_body: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct ReleaseContext {
29    metadata: ReleaseMetadata,
30    branch: String,
31    git_ref: String,
32    previous_tag: Option<String>,
33    commits_since_previous: usize,
34    commit_summaries: Vec<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct ReleaseVerification {
39    command: String,
40    passed: bool,
41    summary: String,
42    details: Option<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ReleaseRecord {
47    pub ts: String,
48    pub package_name: Option<String>,
49    pub version: Option<String>,
50    pub tag: Option<String>,
51    pub git_ref: Option<String>,
52    pub branch: Option<String>,
53    pub previous_tag: Option<String>,
54    pub commits_since_previous: Option<usize>,
55    pub verification_command: Option<String>,
56    pub verification_summary: Option<String>,
57    pub success: bool,
58    pub reason: String,
59    pub details: Option<String>,
60    pub notes_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Default)]
64struct ReleaseDraft {
65    package_name: Option<String>,
66    version: Option<String>,
67    tag: Option<String>,
68    git_ref: Option<String>,
69    branch: Option<String>,
70    previous_tag: Option<String>,
71    commits_since_previous: Option<usize>,
72    verification_command: Option<String>,
73    verification_summary: Option<String>,
74    notes_path: Option<String>,
75}
76
77#[derive(Debug, Clone)]
78struct ReleaseFailure {
79    record: ReleaseRecord,
80    report_markdown: String,
81    message: String,
82}
83
84trait VerificationRunner {
85    fn run(&self, project_root: &Path) -> Result<ReleaseVerification>;
86}
87
88struct ConfiguredVerificationRunner {
89    command_override: Option<String>,
90}
91
92impl VerificationRunner for ConfiguredVerificationRunner {
93    fn run(&self, project_root: &Path) -> Result<ReleaseVerification> {
94        let command = resolve_verification_command(project_root, self.command_override.as_deref())?;
95        let run = run_automatic_verification(project_root, Some(&command)).with_context(|| {
96            format!("failed while running release verification command `{command}`")
97        })?;
98        let summary = if run.passed {
99            run.results
100                .summary
101                .clone()
102                .unwrap_or_else(|| format!("{command} passed"))
103        } else if !run.failures.is_empty() {
104            run.failures.join("; ")
105        } else {
106            run.results.failure_summary()
107        };
108        let details = trimmed_output(&run.output);
109
110        Ok(ReleaseVerification {
111            command,
112            passed: run.passed,
113            summary,
114            details,
115        })
116    }
117}
118
119pub fn cmd_release(project_root: &Path, requested_tag: Option<&str>) -> Result<()> {
120    let verifier = ConfiguredVerificationRunner {
121        command_override: None,
122    };
123
124    match run_release_with_verifier(project_root, requested_tag, &verifier) {
125        Ok((record, report_markdown)) => {
126            persist_release_record(project_root, &record)?;
127            write_latest_report(project_root, &report_markdown)?;
128            emit_release_record(project_root, &record)?;
129            println!(
130                "Release succeeded: {} -> {}",
131                record.tag.as_deref().unwrap_or("unknown-tag"),
132                record.git_ref.as_deref().unwrap_or("unknown-ref")
133            );
134            if let Some(path) = record.notes_path.as_deref() {
135                println!("Release notes: {path}");
136            }
137            println!(
138                "Verification: {}",
139                record
140                    .verification_summary
141                    .as_deref()
142                    .unwrap_or("no verification summary recorded")
143            );
144            Ok(())
145        }
146        Err(failure) => {
147            persist_release_record(project_root, &failure.record)?;
148            write_latest_report(project_root, &failure.report_markdown)?;
149            emit_release_record(project_root, &failure.record)?;
150            bail!("{}", failure.message);
151        }
152    }
153}
154
155pub fn latest_record(project_root: &Path) -> Result<Option<ReleaseRecord>> {
156    let path = releases_dir(project_root).join(RELEASE_LATEST_JSON);
157    if !path.exists() {
158        return Ok(None);
159    }
160    let content =
161        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
162    let record = serde_json::from_str(&content)
163        .with_context(|| format!("failed to parse {}", path.display()))?;
164    Ok(Some(record))
165}
166
167#[allow(clippy::result_large_err)]
168fn run_release_with_verifier(
169    project_root: &Path,
170    requested_tag: Option<&str>,
171    verifier: &dyn VerificationRunner,
172) -> std::result::Result<(ReleaseRecord, String), ReleaseFailure> {
173    let mut draft = ReleaseDraft::default();
174
175    let metadata = load_release_metadata(project_root, requested_tag).map_err(|error| {
176        failure(
177            &draft,
178            "missing_release_metadata",
179            "release metadata is missing or invalid",
180            Some(error.to_string()),
181        )
182    })?;
183    draft.package_name = Some(metadata.package_name.clone());
184    draft.version = Some(metadata.version.clone());
185    draft.tag = Some(metadata.tag.clone());
186
187    let branch =
188        git_stdout(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]).map_err(|error| {
189            failure(
190                &draft,
191                "git_branch_lookup_failed",
192                "failed to resolve the current branch before releasing",
193                Some(error.to_string()),
194            )
195        })?;
196    draft.branch = Some(branch.clone());
197    if branch != "main" {
198        return Err(failure(
199            &draft,
200            "not_on_main",
201            "release requires the project root to be on `main`",
202            Some(format!("current branch is `{branch}`")),
203        ));
204    }
205
206    let dirty = git_stdout(project_root, &["status", "--porcelain"]).map_err(|error| {
207        failure(
208            &draft,
209            "git_status_failed",
210            "failed to inspect the `main` worktree before releasing",
211            Some(error.to_string()),
212        )
213    })?;
214    if dirty.lines().any(|line| !line.starts_with("?? .batty/")) {
215        return Err(failure(
216            &draft,
217            "dirty_main",
218            "release readiness failed: `main` has uncommitted changes",
219            Some("commit, stash, or remove local changes before releasing".to_string()),
220        ));
221    }
222
223    let git_ref = git_stdout(project_root, &["rev-parse", "main"]).map_err(|error| {
224        failure(
225            &draft,
226            "git_ref_lookup_failed",
227            "failed to resolve the `main` git ref before releasing",
228            Some(error.to_string()),
229        )
230    })?;
231    draft.git_ref = Some(git_ref.clone());
232
233    if git_ref_for_tag(project_root, &metadata.tag)
234        .map_err(|error| {
235            failure(
236                &draft,
237                "tag_lookup_failed",
238                "failed to inspect whether the target release tag already exists",
239                Some(error.to_string()),
240            )
241        })?
242        .is_some()
243    {
244        return Err(failure(
245            &draft,
246            "tag_exists",
247            "release readiness failed: the target tag already exists",
248            Some(format!(
249                "tag `{}` already points at an existing release",
250                metadata.tag
251            )),
252        ));
253    }
254
255    let previous_tag = latest_git_tag(project_root).map_err(|error| {
256        failure(
257            &draft,
258            "previous_tag_lookup_failed",
259            "failed to resolve the previous release tag",
260            Some(error.to_string()),
261        )
262    })?;
263    draft.previous_tag = previous_tag.clone();
264
265    let commits_since_previous = count_commits_since(project_root, previous_tag.as_deref())
266        .map_err(|error| {
267            failure(
268                &draft,
269                "commit_count_failed",
270                "failed to count commits included in this release",
271                Some(error.to_string()),
272            )
273        })?;
274    draft.commits_since_previous = Some(commits_since_previous);
275
276    let verification = verifier.run(project_root).map_err(|error| {
277        failure(
278            &draft,
279            "verification_start_failed",
280            "release verification could not start",
281            Some(error.to_string()),
282        )
283    })?;
284    draft.verification_command = Some(verification.command.clone());
285    draft.verification_summary = Some(verification.summary.clone());
286    if !verification.passed {
287        return Err(failure(
288            &draft,
289            "verification_failed",
290            "release readiness failed: verification is not green",
291            verification
292                .details
293                .clone()
294                .or_else(|| Some(verification.summary.clone())),
295        ));
296    }
297
298    let commit_summaries = collect_commit_summaries(project_root, previous_tag.as_deref())
299        .map_err(|error| {
300            failure(
301                &draft,
302                "commit_summary_failed",
303                "failed to assemble the release commit summary",
304                Some(error.to_string()),
305            )
306        })?;
307
308    let context = ReleaseContext {
309        metadata,
310        branch,
311        git_ref,
312        previous_tag,
313        commits_since_previous,
314        commit_summaries,
315    };
316
317    let notes = render_release_notes(&context, &verification);
318    let notes_path = write_release_notes(project_root, &context, &notes).map_err(|error| {
319        failure(
320            &draft,
321            "notes_write_failed",
322            "failed to write release notes",
323            Some(error.to_string()),
324        )
325    })?;
326    draft.notes_path = Some(notes_path.display().to_string());
327
328    git_ok(
329        project_root,
330        &[
331            "tag",
332            "-a",
333            context.metadata.tag.as_str(),
334            "-F",
335            notes_path.to_string_lossy().as_ref(),
336        ],
337    )
338    .map_err(|error| {
339        failure(
340            &draft,
341            "tag_creation_failed",
342            "failed to create the annotated release tag",
343            Some(error.to_string()),
344        )
345    })?;
346
347    let record = success_record(&context, &verification, &notes_path);
348    Ok((record, notes))
349}
350
351fn releases_dir(project_root: &Path) -> PathBuf {
352    project_root.join(RELEASES_DIR)
353}
354
355fn resolve_verification_command(
356    project_root: &Path,
357    override_command: Option<&str>,
358) -> Result<String> {
359    if let Some(command) = override_command {
360        return Ok(command.trim().to_string());
361    }
362
363    let team_config_path = crate::team::team_config_path(project_root);
364    if !team_config_path.exists() {
365        return Ok("cargo test".to_string());
366    }
367
368    let config = TeamConfig::load(&team_config_path)
369        .with_context(|| format!("failed to load {}", team_config_path.display()))?;
370    let command = config
371        .workflow_policy
372        .verification
373        .test_command
374        .unwrap_or_else(|| "cargo test".to_string());
375    if command.trim().is_empty() {
376        bail!(
377            "{} sets workflow_policy.verification.test_command to an empty value",
378            team_config_path.display()
379        );
380    }
381    Ok(command)
382}
383
384fn load_release_metadata(
385    project_root: &Path,
386    requested_tag: Option<&str>,
387) -> Result<ReleaseMetadata> {
388    #[derive(Deserialize)]
389    struct CargoToml {
390        package: Option<CargoPackage>,
391    }
392
393    #[derive(Deserialize)]
394    struct CargoPackage {
395        name: Option<String>,
396        version: Option<String>,
397    }
398
399    let cargo_toml_path = project_root.join("Cargo.toml");
400    let content = fs::read_to_string(&cargo_toml_path)
401        .with_context(|| format!("failed to read {}", cargo_toml_path.display()))?;
402    let parsed: CargoToml = toml::from_str(&content)
403        .with_context(|| format!("failed to parse {}", cargo_toml_path.display()))?;
404    let package = parsed
405        .package
406        .context("Cargo.toml is missing `[package]` release metadata")?;
407    let package_name = package
408        .name
409        .filter(|value| !value.trim().is_empty())
410        .context("Cargo.toml package.name is required for releases")?;
411    let version = package
412        .version
413        .filter(|value| !value.trim().is_empty())
414        .context("Cargo.toml package.version is required for releases")?;
415
416    let (changelog_heading, changelog_body) = load_changelog_entry(project_root, &version)?;
417    let tag = requested_tag
418        .map(str::trim)
419        .filter(|value| !value.is_empty())
420        .map(str::to_string)
421        .unwrap_or_else(|| format!("v{version}"));
422
423    Ok(ReleaseMetadata {
424        package_name,
425        version,
426        tag,
427        changelog_heading,
428        changelog_body,
429    })
430}
431
432fn load_changelog_entry(project_root: &Path, version: &str) -> Result<(String, String)> {
433    let changelog_path = project_root.join("CHANGELOG.md");
434    let content = fs::read_to_string(&changelog_path)
435        .with_context(|| format!("failed to read {}", changelog_path.display()))?;
436    let lines: Vec<&str> = content.lines().collect();
437    let heading_prefix = format!("## {version}");
438
439    let Some(start_index) = lines
440        .iter()
441        .position(|line| line.trim_start().starts_with(&heading_prefix))
442    else {
443        bail!(
444            "CHANGELOG.md is missing a release heading for version {}",
445            version
446        );
447    };
448
449    let heading = lines[start_index].trim().to_string();
450    let end_index = lines[start_index + 1..]
451        .iter()
452        .position(|line| line.trim_start().starts_with("## "))
453        .map(|offset| start_index + 1 + offset)
454        .unwrap_or(lines.len());
455    let body = lines[start_index + 1..end_index]
456        .join("\n")
457        .trim()
458        .to_string();
459    if body.is_empty() {
460        bail!(
461            "CHANGELOG.md release entry for version {} is empty",
462            version
463        );
464    }
465
466    Ok((heading, body))
467}
468
469fn git_stdout(project_root: &Path, args: &[&str]) -> Result<String> {
470    let output = Command::new("git")
471        .arg("-C")
472        .arg(project_root)
473        .args(args)
474        .output()
475        .with_context(|| format!("failed to run git {}", args.join(" ")))?;
476    if !output.status.success() {
477        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
478        bail!(
479            "git {} failed{}",
480            args.join(" "),
481            if stderr.is_empty() {
482                String::new()
483            } else {
484                format!(": {stderr}")
485            }
486        );
487    }
488    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
489}
490
491fn git_ok(project_root: &Path, args: &[&str]) -> Result<()> {
492    let _ = git_stdout(project_root, args)?;
493    Ok(())
494}
495
496fn git_ref_for_tag(project_root: &Path, tag: &str) -> Result<Option<String>> {
497    let output = Command::new("git")
498        .arg("-C")
499        .arg(project_root)
500        .args(["rev-parse", "-q", "--verify", &format!("refs/tags/{tag}")])
501        .output()
502        .with_context(|| format!("failed to inspect tag `{tag}`"))?;
503    match output.status.code() {
504        Some(0) => Ok(Some(
505            String::from_utf8_lossy(&output.stdout).trim().to_string(),
506        )),
507        Some(1) => Ok(None),
508        _ => {
509            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
510            bail!("git rev-parse tag `{tag}` failed: {stderr}");
511        }
512    }
513}
514
515fn latest_git_tag(project_root: &Path) -> Result<Option<String>> {
516    let output = Command::new("git")
517        .arg("-C")
518        .arg(project_root)
519        .args(["describe", "--tags", "--abbrev=0"])
520        .output()
521        .context("failed to resolve the latest git tag")?;
522    if output.status.success() {
523        return Ok(Some(
524            String::from_utf8_lossy(&output.stdout).trim().to_string(),
525        ));
526    }
527
528    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
529    if stderr.contains("no names found") || stderr.contains("no tags can describe") {
530        return Ok(None);
531    }
532
533    bail!(
534        "git describe --tags --abbrev=0 failed: {}",
535        String::from_utf8_lossy(&output.stderr).trim()
536    )
537}
538
539fn count_commits_since(project_root: &Path, previous_tag: Option<&str>) -> Result<usize> {
540    let count = match previous_tag {
541        Some(tag) => {
542            let range = format!("{tag}..main");
543            git_stdout(project_root, &["rev-list", "--count", &range])?
544        }
545        None => git_stdout(project_root, &["rev-list", "--count", "main"])?,
546    };
547    count
548        .parse::<usize>()
549        .with_context(|| format!("failed to parse commit count `{count}`"))
550}
551
552fn collect_commit_summaries(
553    project_root: &Path,
554    previous_tag: Option<&str>,
555) -> Result<Vec<String>> {
556    let output = match previous_tag {
557        Some(tag) => {
558            let range = format!("{tag}..main");
559            git_stdout(
560                project_root,
561                &["log", "--no-merges", "--format=%h %s", &range],
562            )?
563        }
564        None => git_stdout(
565            project_root,
566            &["log", "--no-merges", "--format=%h %s", "-n", "20", "main"],
567        )?,
568    };
569    Ok(output
570        .lines()
571        .map(str::trim)
572        .filter(|line| !line.is_empty())
573        .map(str::to_string)
574        .collect())
575}
576
577fn render_release_notes(context: &ReleaseContext, verification: &ReleaseVerification) -> String {
578    let mut out = String::new();
579    out.push_str("# Release Notes\n\n");
580    out.push_str(&format!("- Package: {}\n", context.metadata.package_name));
581    out.push_str(&format!("- Version: {}\n", context.metadata.version));
582    out.push_str(&format!("- Tag: {}\n", context.metadata.tag));
583    out.push_str(&format!("- Git Ref: {}\n", context.git_ref));
584    out.push_str(&format!("- Branch: {}\n", context.branch));
585    out.push_str(&format!(
586        "- Previous Tag: {}\n",
587        context.previous_tag.as_deref().unwrap_or("none")
588    ));
589    out.push_str(&format!(
590        "- Commits Since Previous Tag: {}\n",
591        context.commits_since_previous
592    ));
593    out.push_str(&format!(
594        "- Verification Command: {}\n",
595        verification.command
596    ));
597    out.push_str(&format!(
598        "- Verification Summary: {}\n",
599        verification.summary
600    ));
601    out.push_str(&format!(
602        "- Generated: {}\n\n",
603        chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
604    ));
605
606    out.push_str("## Changelog\n\n");
607    out.push_str(&context.metadata.changelog_heading);
608    out.push_str("\n\n");
609    out.push_str(&context.metadata.changelog_body);
610    out.push_str("\n\n");
611
612    out.push_str("## Included Commits\n\n");
613    if context.commit_summaries.is_empty() {
614        out.push_str("- No commits were found in the selected release window.\n");
615    } else {
616        for commit in &context.commit_summaries {
617            out.push_str("- ");
618            out.push_str(commit);
619            out.push('\n');
620        }
621    }
622
623    out
624}
625
626fn render_attempt_report(record: &ReleaseRecord) -> String {
627    let mut out = String::new();
628    out.push_str("# Release Attempt\n\n");
629    out.push_str(&format!(
630        "- Status: {}\n",
631        if record.success { "success" } else { "failure" }
632    ));
633    if let Some(package_name) = record.package_name.as_deref() {
634        out.push_str(&format!("- Package: {package_name}\n"));
635    }
636    if let Some(version) = record.version.as_deref() {
637        out.push_str(&format!("- Version: {version}\n"));
638    }
639    if let Some(tag) = record.tag.as_deref() {
640        out.push_str(&format!("- Tag: {tag}\n"));
641    }
642    if let Some(git_ref) = record.git_ref.as_deref() {
643        out.push_str(&format!("- Git Ref: {git_ref}\n"));
644    }
645    if let Some(branch) = record.branch.as_deref() {
646        out.push_str(&format!("- Branch: {branch}\n"));
647    }
648    if let Some(previous_tag) = record.previous_tag.as_deref() {
649        out.push_str(&format!("- Previous Tag: {previous_tag}\n"));
650    }
651    if let Some(commits_since_previous) = record.commits_since_previous {
652        out.push_str(&format!(
653            "- Commits Since Previous Tag: {commits_since_previous}\n"
654        ));
655    }
656    if let Some(command) = record.verification_command.as_deref() {
657        out.push_str(&format!("- Verification Command: {command}\n"));
658    }
659    if let Some(summary) = record.verification_summary.as_deref() {
660        out.push_str(&format!("- Verification Summary: {summary}\n"));
661    }
662    out.push_str(&format!("- Timestamp: {}\n\n", record.ts));
663    out.push_str("## Outcome\n\n");
664    out.push_str(&record.reason);
665    out.push_str("\n\n");
666    if let Some(details) = record.details.as_deref() {
667        out.push_str("## Details\n\n");
668        out.push_str("```\n");
669        out.push_str(details.trim());
670        out.push_str("\n```\n");
671    }
672    if let Some(path) = record.notes_path.as_deref() {
673        out.push_str("\n## Release Notes Path\n\n");
674        out.push_str(path);
675        out.push('\n');
676    }
677    out
678}
679
680fn write_release_notes(
681    project_root: &Path,
682    context: &ReleaseContext,
683    notes: &str,
684) -> Result<PathBuf> {
685    let dir = releases_dir(project_root);
686    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
687    let path = dir.join(format!("{}.md", context.metadata.tag));
688    fs::write(&path, notes).with_context(|| format!("failed to write {}", path.display()))?;
689    Ok(path)
690}
691
692fn write_latest_report(project_root: &Path, report_markdown: &str) -> Result<()> {
693    let dir = releases_dir(project_root);
694    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
695    let latest_path = dir.join(RELEASE_LATEST_MARKDOWN);
696    fs::write(&latest_path, report_markdown)
697        .with_context(|| format!("failed to write {}", latest_path.display()))?;
698    Ok(())
699}
700
701fn persist_release_record(project_root: &Path, record: &ReleaseRecord) -> Result<()> {
702    let dir = releases_dir(project_root);
703    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
704
705    let history_path = dir.join(RELEASE_HISTORY_FILE);
706    let mut history = OpenOptions::new()
707        .create(true)
708        .append(true)
709        .open(&history_path)
710        .with_context(|| format!("failed to open {}", history_path.display()))?;
711    writeln!(history, "{}", serde_json::to_string(record)?)
712        .with_context(|| format!("failed to append {}", history_path.display()))?;
713
714    let latest_path = dir.join(RELEASE_LATEST_JSON);
715    fs::write(&latest_path, serde_json::to_vec_pretty(record)?)
716        .with_context(|| format!("failed to write {}", latest_path.display()))?;
717    Ok(())
718}
719
720fn emit_release_record(project_root: &Path, record: &ReleaseRecord) -> Result<()> {
721    let event = if record.success {
722        TeamEvent::release_succeeded(
723            record.version.as_deref().unwrap_or("unknown"),
724            record.git_ref.as_deref().unwrap_or("unknown"),
725            record.tag.as_deref().unwrap_or("unknown"),
726            record.notes_path.as_deref(),
727        )
728    } else {
729        TeamEvent::release_failed(
730            record.version.as_deref(),
731            record.git_ref.as_deref(),
732            record.tag.as_deref(),
733            &record.reason,
734            record.details.as_deref(),
735        )
736    };
737
738    let mut sink = EventSink::new(&crate::team::team_events_path(project_root))?;
739    sink.emit(event.clone())?;
740
741    let conn = crate::team::telemetry_db::open(project_root)?;
742    crate::team::telemetry_db::insert_event(&conn, &event)?;
743    Ok(())
744}
745
746fn success_record(
747    context: &ReleaseContext,
748    verification: &ReleaseVerification,
749    notes_path: &Path,
750) -> ReleaseRecord {
751    ReleaseRecord {
752        ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
753        package_name: Some(context.metadata.package_name.clone()),
754        version: Some(context.metadata.version.clone()),
755        tag: Some(context.metadata.tag.clone()),
756        git_ref: Some(context.git_ref.clone()),
757        branch: Some(context.branch.clone()),
758        previous_tag: context.previous_tag.clone(),
759        commits_since_previous: Some(context.commits_since_previous),
760        verification_command: Some(verification.command.clone()),
761        verification_summary: Some(verification.summary.clone()),
762        success: true,
763        reason: format!(
764            "created annotated tag `{}` from clean green main",
765            context.metadata.tag
766        ),
767        details: Some(
768            serde_json::json!({
769                "previous_tag": context.previous_tag,
770                "commits_since_previous": context.commits_since_previous,
771                "notes_path": notes_path.display().to_string(),
772            })
773            .to_string(),
774        ),
775        notes_path: Some(notes_path.display().to_string()),
776    }
777}
778
779fn failure(
780    draft: &ReleaseDraft,
781    reason_code: &str,
782    message: &str,
783    details: Option<String>,
784) -> ReleaseFailure {
785    let record = ReleaseRecord {
786        ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
787        package_name: draft.package_name.clone(),
788        version: draft.version.clone(),
789        tag: draft.tag.clone(),
790        git_ref: draft.git_ref.clone(),
791        branch: draft.branch.clone(),
792        previous_tag: draft.previous_tag.clone(),
793        commits_since_previous: draft.commits_since_previous,
794        verification_command: draft.verification_command.clone(),
795        verification_summary: draft.verification_summary.clone(),
796        success: false,
797        reason: reason_code.to_string(),
798        details,
799        notes_path: draft.notes_path.clone(),
800    };
801    let report_markdown = render_attempt_report(&record);
802    ReleaseFailure {
803        record,
804        report_markdown,
805        message: message.to_string(),
806    }
807}
808
809fn trimmed_output(output: &str) -> Option<String> {
810    let trimmed = output.trim();
811    if trimmed.is_empty() {
812        None
813    } else {
814        Some(trimmed.to_string())
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use std::sync::Arc;
822    use std::sync::atomic::{AtomicUsize, Ordering};
823
824    struct StubVerifier {
825        result: ReleaseVerification,
826        calls: Arc<AtomicUsize>,
827    }
828
829    impl VerificationRunner for StubVerifier {
830        fn run(&self, _project_root: &Path) -> Result<ReleaseVerification> {
831            self.calls.fetch_add(1, Ordering::SeqCst);
832            Ok(self.result.clone())
833        }
834    }
835
836    fn git(repo: &Path, args: &[&str]) {
837        let status = Command::new("git")
838            .arg("-C")
839            .arg(repo)
840            .args(args)
841            .status()
842            .unwrap();
843        assert!(status.success(), "git {:?} failed", args);
844    }
845
846    fn git_output(repo: &Path, args: &[&str]) -> String {
847        let output = Command::new("git")
848            .arg("-C")
849            .arg(repo)
850            .args(args)
851            .output()
852            .unwrap();
853        assert!(output.status.success(), "git {:?} failed", args);
854        String::from_utf8_lossy(&output.stdout).trim().to_string()
855    }
856
857    fn init_repo() -> tempfile::TempDir {
858        let tmp = tempfile::tempdir().unwrap();
859        fs::write(
860            tmp.path().join("Cargo.toml"),
861            "[package]\nname = \"batty\"\nversion = \"0.10.0\"\nedition = \"2024\"\n",
862        )
863        .unwrap();
864        fs::write(
865            tmp.path().join("CHANGELOG.md"),
866            "# Changelog\n\n## 0.10.0 - 2026-04-10\n\n- Ship release automation.\n\n## 0.9.0 - 2026-04-07\n\n- Previous release.\n",
867        )
868        .unwrap();
869        fs::write(tmp.path().join("README.md"), "hello\n").unwrap();
870        git(tmp.path(), &["init", "-b", "main"]);
871        git(tmp.path(), &["config", "user.name", "Batty Tests"]);
872        git(tmp.path(), &["config", "user.email", "batty@example.com"]);
873        git(tmp.path(), &["add", "."]);
874        git(tmp.path(), &["commit", "-m", "Initial commit"]);
875        git(tmp.path(), &["tag", "v0.9.0"]);
876        fs::write(tmp.path().join("src.txt"), "feature\n").unwrap();
877        git(tmp.path(), &["add", "src.txt"]);
878        git(tmp.path(), &["commit", "-m", "Add release-ready change"]);
879        tmp
880    }
881
882    #[test]
883    fn release_fails_when_changelog_entry_is_missing() {
884        let tmp = tempfile::tempdir().unwrap();
885        fs::write(
886            tmp.path().join("Cargo.toml"),
887            "[package]\nname = \"batty\"\nversion = \"0.10.0\"\nedition = \"2024\"\n",
888        )
889        .unwrap();
890        fs::write(
891            tmp.path().join("CHANGELOG.md"),
892            "# Changelog\n\n## 0.9.0\n\n- old.\n",
893        )
894        .unwrap();
895        git(tmp.path(), &["init", "-b", "main"]);
896        git(tmp.path(), &["config", "user.name", "Batty Tests"]);
897        git(tmp.path(), &["config", "user.email", "batty@example.com"]);
898        let calls = Arc::new(AtomicUsize::new(0));
899        let verifier = StubVerifier {
900            result: ReleaseVerification {
901                command: "cargo test".to_string(),
902                passed: true,
903                summary: "ok".to_string(),
904                details: None,
905            },
906            calls: calls.clone(),
907        };
908
909        let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
910        assert_eq!(error.record.reason, "missing_release_metadata");
911        assert_eq!(calls.load(Ordering::SeqCst), 0);
912    }
913
914    #[test]
915    fn release_fails_when_repo_is_dirty() {
916        let tmp = init_repo();
917        fs::write(tmp.path().join("README.md"), "dirty\n").unwrap();
918        let calls = Arc::new(AtomicUsize::new(0));
919        let verifier = StubVerifier {
920            result: ReleaseVerification {
921                command: "cargo test".to_string(),
922                passed: true,
923                summary: "ok".to_string(),
924                details: None,
925            },
926            calls: calls.clone(),
927        };
928
929        let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
930        assert_eq!(error.record.reason, "dirty_main");
931        assert_eq!(calls.load(Ordering::SeqCst), 0);
932    }
933
934    #[test]
935    fn release_fails_when_verification_is_not_green() {
936        let tmp = init_repo();
937        let calls = Arc::new(AtomicUsize::new(0));
938        let verifier = StubVerifier {
939            result: ReleaseVerification {
940                command: "cargo test".to_string(),
941                passed: false,
942                summary: "1 tests failed: suite::it_breaks".to_string(),
943                details: Some("suite::it_breaks".to_string()),
944            },
945            calls: calls.clone(),
946        };
947
948        let error = run_release_with_verifier(tmp.path(), None, &verifier).unwrap_err();
949        assert_eq!(error.record.reason, "verification_failed");
950        assert_eq!(error.record.details.as_deref(), Some("suite::it_breaks"));
951        assert_eq!(calls.load(Ordering::SeqCst), 1);
952    }
953
954    #[test]
955    fn render_release_notes_includes_changelog_and_verification() {
956        let context = ReleaseContext {
957            metadata: ReleaseMetadata {
958                package_name: "batty".to_string(),
959                version: "0.10.0".to_string(),
960                tag: "v0.10.0".to_string(),
961                changelog_heading: "## 0.10.0 - 2026-04-10".to_string(),
962                changelog_body: "- Ship release automation.".to_string(),
963            },
964            branch: "main".to_string(),
965            git_ref: "abc123".to_string(),
966            previous_tag: Some("v0.9.0".to_string()),
967            commits_since_previous: 2,
968            commit_summaries: vec!["abc123 Improve release command".to_string()],
969        };
970        let verification = ReleaseVerification {
971            command: "cargo test".to_string(),
972            passed: true,
973            summary: "cargo test passed".to_string(),
974            details: None,
975        };
976
977        let notes = render_release_notes(&context, &verification);
978        assert!(notes.contains("Version: 0.10.0"));
979        assert!(notes.contains("Tag: v0.10.0"));
980        assert!(notes.contains("Git Ref: abc123"));
981        assert!(notes.contains("Verification Summary: cargo test passed"));
982        assert!(notes.contains("## 0.10.0 - 2026-04-10"));
983        assert!(notes.contains("abc123 Improve release command"));
984    }
985
986    #[test]
987    fn release_success_creates_tag_notes_and_recordable_context() {
988        let tmp = init_repo();
989        let calls = Arc::new(AtomicUsize::new(0));
990        let verifier = StubVerifier {
991            result: ReleaseVerification {
992                command: "cargo test".to_string(),
993                passed: true,
994                summary: "cargo test passed".to_string(),
995                details: None,
996            },
997            calls: calls.clone(),
998        };
999
1000        let (record, report_markdown) =
1001            run_release_with_verifier(tmp.path(), None, &verifier).unwrap();
1002        let tag = record.tag.clone().unwrap();
1003        let notes_path = PathBuf::from(record.notes_path.clone().unwrap());
1004
1005        assert!(record.success);
1006        assert_eq!(record.version.as_deref(), Some("0.10.0"));
1007        assert_eq!(record.branch.as_deref(), Some("main"));
1008        assert_eq!(record.previous_tag.as_deref(), Some("v0.9.0"));
1009        assert!(notes_path.exists());
1010        assert_eq!(git_output(tmp.path(), &["tag", "--list", &tag]), tag);
1011        assert!(report_markdown.contains("## Changelog"));
1012        assert_eq!(calls.load(Ordering::SeqCst), 1);
1013
1014        persist_release_record(tmp.path(), &record).unwrap();
1015        write_latest_report(tmp.path(), &report_markdown).unwrap();
1016        emit_release_record(tmp.path(), &record).unwrap();
1017
1018        let events = fs::read_to_string(
1019            tmp.path()
1020                .join(".batty")
1021                .join("team_config")
1022                .join("events.jsonl"),
1023        )
1024        .unwrap();
1025        assert!(events.contains("\"event\":\"release_succeeded\""));
1026        assert!(events.contains("\"version\":\"0.10.0\""));
1027        assert!(events.contains("\"git_ref\""));
1028    }
1029
1030    #[test]
1031    fn release_uses_tag_override() {
1032        let tmp = init_repo();
1033        let calls = Arc::new(AtomicUsize::new(0));
1034        let verifier = StubVerifier {
1035            result: ReleaseVerification {
1036                command: "cargo test".to_string(),
1037                passed: true,
1038                summary: "cargo test passed".to_string(),
1039                details: None,
1040            },
1041            calls: calls.clone(),
1042        };
1043
1044        let (record, _) =
1045            run_release_with_verifier(tmp.path(), Some("batty-2026-04-10"), &verifier).unwrap();
1046        assert_eq!(record.tag.as_deref(), Some("batty-2026-04-10"));
1047        assert_eq!(
1048            git_output(tmp.path(), &["tag", "--list", "batty-2026-04-10"]),
1049            "batty-2026-04-10"
1050        );
1051    }
1052
1053    #[test]
1054    fn latest_record_reads_latest_json_snapshot() {
1055        let tmp = tempfile::tempdir().unwrap();
1056        let record = ReleaseRecord {
1057            ts: "2026-04-10T00:00:00Z".to_string(),
1058            package_name: Some("batty".to_string()),
1059            version: Some("0.10.0".to_string()),
1060            tag: Some("v0.10.0".to_string()),
1061            git_ref: Some("abc123".to_string()),
1062            branch: Some("main".to_string()),
1063            previous_tag: Some("v0.9.0".to_string()),
1064            commits_since_previous: Some(12),
1065            verification_command: Some("cargo test".to_string()),
1066            verification_summary: Some("cargo test passed".to_string()),
1067            success: true,
1068            reason: "ok".to_string(),
1069            details: None,
1070            notes_path: Some("/tmp/v0.10.0.md".to_string()),
1071        };
1072        persist_release_record(tmp.path(), &record).unwrap();
1073
1074        let loaded = latest_record(tmp.path()).unwrap().unwrap();
1075        assert_eq!(loaded, record);
1076    }
1077
1078    #[test]
1079    fn configured_verification_runner_respects_override_command() {
1080        let tmp = init_repo();
1081        let runner = ConfiguredVerificationRunner {
1082            command_override: Some(
1083                "printf 'test result: ok. 1 passed; 0 failed; 0 ignored;\\n'".to_string(),
1084            ),
1085        };
1086
1087        let verification = runner.run(tmp.path()).unwrap();
1088        assert_eq!(
1089            verification.command,
1090            "printf 'test result: ok. 1 passed; 0 failed; 0 ignored;\\n'"
1091        );
1092        assert!(verification.passed);
1093        assert!(verification.summary.contains("1 passed"));
1094    }
1095}