use std::path::PathBuf;
use serde_json::Value;
use soothfast_report::{badges, changelog, llms, perf_table, trend_chart};
use crate::{docs_support, invoke, workspace};
pub fn run(args: &[String]) -> i32 {
match args.first().map(String::as_str) {
Some("render") => render(&args[1..]),
Some("changelog") => changelog_cmd(&args[1..]),
_ => {
eprintln!(
"soothfast: usage: cargo soothfast report render|changelog -p PKG [-p PKG ...] [--out DIR|FILE] [--against-ref REF]"
);
2
}
}
}
struct ReportArgs {
pkg: Vec<String>,
baseline: String,
out: Option<PathBuf>,
against_ref: Option<String>,
features: Option<String>,
bot_author: Option<String>,
}
fn parse(args: &[String]) -> Result<ReportArgs, String> {
let mut a = ReportArgs {
pkg: Vec::new(),
baseline: "base".into(),
out: None,
against_ref: None,
features: None,
bot_author: None,
};
let mut it = args.iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"-p" | "--package" => a
.pkg
.push(it.next().cloned().ok_or("-p needs a package name")?),
"--baseline" => a.baseline = it.next().cloned().ok_or("--baseline needs a name")?,
"--out" => a.out = it.next().map(PathBuf::from),
"--against-ref" => a.against_ref = it.next().cloned(),
"--features" => a.features = it.next().cloned(),
"--bot-author" => a.bot_author = it.next().cloned(),
other => return Err(format!("unknown report arg {other:?}")),
}
}
Ok(a)
}
fn load_baseline_required(name: &str) -> Result<Value, String> {
match invoke::load_baseline(name) {
Ok(Some(b)) => Ok(b),
Ok(None) => Err(format!(
"no baseline {name:?} — run `cargo soothfast measure --save-baseline {name}`"
)),
Err(e) => Err(e.to_string()),
}
}
fn render(args: &[String]) -> i32 {
let a = match parse(args) {
Ok(a) => a,
Err(e) => return err(&e),
};
let baseline = match load_baseline_required(&a.baseline) {
Ok(b) => b,
Err(e) => return err(&e),
};
let root = match invoke::workspace_root() {
Ok(r) => r,
Err(e) => return err(&e.to_string()),
};
let out_dir = a.out.clone().unwrap_or_else(|| root.join("docs/perf"));
let _ = std::fs::create_dir_all(&out_dir);
let _ = std::fs::create_dir_all(out_dir.join("badges"));
let mut summary =
String::from("# Performance summary\n\n> Generated by `cargo soothfast report`\n\n");
summary.push_str(&perf_table::markdown(&baseline));
let trend_path = root.join(".soothfast").join("trend.jsonl");
if let Ok(text) = std::fs::read_to_string(&trend_path) {
let points: Vec<Value> = text
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
for (key, name, threshold, rel) in trend_chart::METRICS {
if let Some(svg) = trend_chart::render(&points, key, name, *threshold, *rel) {
let file = format!("trend-{name}.svg");
if std::fs::write(out_dir.join(&file), svg).is_ok() {
summary.push_str(&format!("\n\n"));
println!("report: wrote {}", out_dir.join(&file).display());
}
}
}
}
let gate_passed = std::fs::read_to_string(root.join(".soothfast/gate-status.json"))
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v["passed"].as_bool());
let gate = badges::gate_badge(gate_passed);
let writes: &[(&str, String)] = &[
("summary.md", summary),
("summary.html", perf_table::html(&baseline)),
("badges/gate.json", gate.to_string()),
("badges/gate.svg", badges::svg_from(&gate)),
];
for (file, content) in writes {
if std::fs::write(out_dir.join(file), content).is_err() {
return err(&format!("cannot write {file}"));
}
println!("report: wrote {}", out_dir.join(file).display());
}
if a.pkg.is_empty() {
println!(
"report: skipped llms.txt (pass -p PKG to generate; repeat -p for multiple crates)"
);
} else {
let mut entries = Vec::new();
for pkg in &a.pkg {
match docs_support::surface_entries(pkg, a.features.as_deref()) {
Ok(e) => entries.extend(e),
Err(e) => return err(&format!("llms.txt needs the API surface of {pkg}: {e}")),
}
}
let label = if a.pkg.len() == 1 {
a.pkg[0].clone()
} else {
a.pkg.join(", ")
};
let text = llms::render(&label, &entries, &baseline);
let path = out_dir.join("llms.txt");
if std::fs::write(&path, text).is_err() {
return err("cannot write llms.txt");
}
println!("report: wrote {}", path.display());
}
0
}
fn surface_diffs(pkgs: &[String], refname: &str, features: Option<&str>) -> Result<String, String> {
let mut out = String::new();
for pkg in pkgs {
let text = docs_support::surface_diff_text(pkg, refname, features)?;
if text.trim().is_empty() {
continue;
}
if pkgs.len() > 1 {
out.push_str(&format!("# {pkg}\n"));
}
out.push_str(text.trim_end());
out.push_str("\n\n");
}
Ok(out)
}
fn measure_ref(a: &ReportArgs, refname: &str) -> Result<Option<Value>, String> {
let pkgs = a.pkg.clone();
let features = a.features.clone();
println!("report: measuring {refname} in a worktree for the perf delta");
let runs = invoke::with_merge_base_worktree(refname, |wt| {
let mut runs = Vec::new();
for pkg in &pkgs {
if !workspace::has_bench_target(pkg, "soothfast", Some(wt))
.map_err(|e| e.to_string())?
{
continue;
}
let common = invoke::CommonArgs {
pkg: Some(pkg.clone()),
features: features.clone(),
..Default::default()
};
let records =
invoke::run_bench_in(&common, &[], Some(wt)).map_err(|e| e.to_string())?;
runs.push(invoke::collect(&records));
}
Ok(runs)
})?;
if runs.is_empty() {
return Ok(None);
}
let mut items = serde_json::Map::new();
let mut noise: Option<f64> = None;
for run in &runs {
if let Some(obj) = invoke::run_to_items_value(run).as_object() {
items.extend(obj.clone());
}
if let Some(n) = run.noise_pct {
noise = Some(noise.map_or(n, |m: f64| m.max(n)));
}
}
let mut doc = serde_json::json!({ "version": 1, "items": items });
if let Some(n) = noise {
doc["noise_pct"] = serde_json::json!(n);
}
Ok(Some(doc))
}
fn changelog_cmd(args: &[String]) -> i32 {
let mut a = match parse(args) {
Ok(a) => a,
Err(e) => return err(&e),
};
let root = match invoke::workspace_root() {
Ok(r) => r,
Err(e) => return err(&e.to_string()),
};
let cfg = match crate::changelog_config::load(&root) {
Ok(c) => c,
Err(e) => return err(&e),
};
if a.pkg.is_empty() {
a.pkg = cfg.packages;
}
if a.pkg.is_empty() {
return err("report changelog needs -p PKG");
}
let gate_cfg = match crate::gate_config::load(&root) {
Ok(g) => g,
Err(e) => return err(&e),
};
a.features = resolve_features(
a.features.take(),
cfg.features.as_deref(),
gate_cfg.features.as_deref(),
);
let path = a.out.clone().unwrap_or_else(|| root.join("CHANGELOG.md"));
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if changelog_already_cut(&existing, a.against_ref.as_deref()) {
println!(
"report: {} already has a released top section — nothing to regenerate",
path.display()
);
return 0;
}
let baseline = match load_baseline_required(&a.baseline) {
Ok(b) => b,
Err(e) => return err(&e),
};
let (diff_text, entries) = match &a.against_ref {
Some(refname) => match surface_diffs(&a.pkg, refname, a.features.as_deref()) {
Ok(t) => (t, Vec::new()),
Err(e) => return err(&e),
},
None => {
let mut all = Vec::new();
for pkg in &a.pkg {
match docs_support::surface_entries(pkg, a.features.as_deref()) {
Ok(e) => all.extend(e),
Err(e) => return err(&e),
}
}
(String::new(), all)
}
};
let ref_baseline = match &a.against_ref {
Some(refname) => match measure_ref(&a, refname) {
Ok(b) => b,
Err(e) => return err(&e),
},
None => None,
};
let bot_author = resolve_bot_author(a.bot_author.take(), cfg.bot_author.as_deref());
let changes = match &a.against_ref {
Some(refname) => match merged_changes(refname, &bot_author) {
Ok(c) => c,
Err(e) => return err(&e),
},
None => Vec::new(),
};
let text = changelog::draft(&changelog::DraftInputs {
changes: &changes,
icons: &cfg.icons,
api: match &a.against_ref {
Some(refname) => changelog::ApiSection::Diff {
against: refname,
text: &diff_text,
},
None => changelog::ApiSection::Initial(&entries),
},
old_baseline: ref_baseline.as_ref(),
new_baseline: &baseline,
thresholds: changelog::PerfThresholds {
instructions_pct: crate::gate::INSTRUCTIONS_THRESHOLD_PCT,
allocs_pct: crate::gate::ALLOC_THRESHOLD_PCT as f64,
},
});
if std::fs::write(&path, merge_changelog(&existing, &text)).is_err() {
return err(&format!("cannot write {}", path.display()));
}
println!("report: regenerated {}", path.display());
0
}
fn resolve_features(
cli: Option<String>,
changelog: Option<&str>,
gate: Option<&str>,
) -> Option<String> {
cli.or_else(|| changelog.or(gate).map(str::to_string))
}
fn resolve_bot_author(cli: Option<String>, config: Option<&str>) -> String {
cli.unwrap_or_else(|| config.unwrap_or(DEFAULT_BOT_AUTHOR).to_string())
}
const DEFAULT_BOT_AUTHOR: &str = "soothfast-bot[bot]";
const LOG_FIELD_SEP: char = '\u{1f}';
fn subjects_excluding_author(log: &str, bot_author: &str) -> Vec<String> {
log.lines()
.filter_map(|line| line.split_once(LOG_FIELD_SEP))
.filter(|(author, _)| *author != bot_author)
.map(|(_, subject)| subject.to_string())
.collect()
}
fn merged_changes(refname: &str, bot_author: &str) -> Result<Vec<changelog::Change>, String> {
let range = format!("{refname}..HEAD");
let log = invoke::git(&["log", "--format=%an%x1f%s", &range]).map_err(|e| e.to_string())?;
let subjects = subjects_excluding_author(&log, bot_author);
let unparsed = changelog::unparsed_subjects(&subjects);
if !unparsed.is_empty() {
println!(
"report: {} subject(s) since {refname} are not conventional commits and are left out:",
unparsed.len()
);
for subject in unparsed {
println!(" {subject}");
}
}
Ok(changelog::changes_from_subjects(&subjects))
}
fn changelog_already_cut(existing: &str, against_ref: Option<&str>) -> bool {
let Some(heading) = existing.lines().find_map(|l| l.strip_prefix("## ")) else {
return false;
};
let heading = heading.trim_start();
if heading.starts_with("Unreleased") || heading.starts_with("[Unreleased]") {
return false;
}
let heading_version = heading
.split_whitespace()
.next()
.unwrap_or("")
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or("");
match against_ref {
Some(r) => heading_version != r.trim_start_matches('v'),
None => true,
}
}
const NOTES_START: &str = "<!-- soothfast:notes -->";
const NOTES_END: &str = "<!-- /soothfast:notes -->";
const NOTES_SCAFFOLD: &str = "\
<!-- ### Overview -->
<!-- What this release means for someone using it. One paragraph. -->
<!-- ### Upgrade notes -->
<!-- What a consumer has to do. \"Nothing\" is a useful answer. -->
";
fn extract_notes(unreleased: &str) -> Option<&str> {
let start = unreleased.find(NOTES_START)? + NOTES_START.len();
let end = unreleased[start..].find(NOTES_END)? + start;
Some(unreleased[start..end].trim())
}
fn merge_changelog(existing: &str, draft: &str) -> String {
let mut preamble = String::new();
let mut released = String::new();
let mut unreleased = String::new();
let mut seen_section = false;
let mut keep = false;
for line in existing.lines() {
if let Some(h) = line.strip_prefix("## ") {
seen_section = true;
let h = h.trim_start();
keep = !(h.starts_with("Unreleased") || h.starts_with("[Unreleased]"));
}
if !seen_section {
if line.trim() != "# Changelog" {
preamble.push_str(line);
preamble.push('\n');
}
} else if keep {
released.push_str(line);
released.push('\n');
} else {
unreleased.push_str(line);
unreleased.push('\n');
}
}
let mut doc = String::from("# Changelog\n");
let pre = preamble.trim();
if !pre.is_empty() {
doc.push('\n');
doc.push_str(pre);
doc.push('\n');
}
doc.push('\n');
match extract_notes(&unreleased).filter(|n| !n.is_empty()) {
Some(notes) => {
let (heading, rest) = draft.split_once('\n').unwrap_or((draft, ""));
doc.push_str(heading);
doc.push('\n');
doc.push('\n');
doc.push_str(NOTES_START);
doc.push('\n');
doc.push_str(notes);
doc.push('\n');
doc.push_str(NOTES_END);
doc.push('\n');
doc.push('\n');
doc.push_str(rest.trim_start_matches('\n'));
}
None => {
let (heading, rest) = draft.split_once('\n').unwrap_or((draft, ""));
doc.push_str(heading);
doc.push_str("\n\n");
doc.push_str(NOTES_START);
doc.push('\n');
doc.push_str(NOTES_SCAFFOLD);
doc.push_str(NOTES_END);
doc.push_str("\n\n");
doc.push_str(rest.trim_start_matches('\n'));
}
}
doc.push('\n');
if !released.is_empty() {
doc.push('\n');
doc.push_str(released.trim_end());
doc.push('\n');
}
doc
}
fn err(msg: &str) -> i32 {
eprintln!("soothfast: {msg}");
2
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_BOT_AUTHOR, changelog_already_cut, merge_changelog, resolve_bot_author,
resolve_features, subjects_excluding_author,
};
#[test]
fn a_release_pr_still_diffing_against_the_previous_tag_is_already_cut() {
assert!(changelog_already_cut(
"# Changelog\n\n## 0.1.3 - 2026-08-09\n\n- shipped\n",
Some("v0.1.2")
));
assert!(changelog_already_cut(
"# Changelog\n\n## [2.8.0] - 2026-07-10\n\n- shipped\n",
Some("v2.7.0")
));
}
#[test]
fn a_release_pr_with_no_prior_tag_at_all_is_already_cut() {
assert!(changelog_already_cut(
"# Changelog\n\n## 0.1.0 - 2026-08-01\n\n- shipped\n",
None
));
}
#[test]
fn a_normal_pr_after_the_tag_catches_up_is_not_already_cut() {
assert!(!changelog_already_cut(
"# Changelog\n\n## 0.1.3 - 2026-08-09\n\n- shipped\n",
Some("v0.1.3")
));
assert!(!changelog_already_cut(
"# Changelog\n\n## [3.0.0] - 2026-08-30\n\n- shipped\n",
Some("v3.0.0")
));
assert!(!changelog_already_cut(
"# Changelog\n\n## [3.0.0](https://github.com/o/r/compare/v2.9.0...v3.0.0) - 2026-08-30\n\n- shipped\n",
Some("v3.0.0")
));
}
#[test]
fn a_normal_pr_with_an_unreleased_section_is_not_already_cut() {
assert!(!changelog_already_cut(
"# Changelog\n\n## Unreleased (draft vs v0.1.1)\n\nold\n",
Some("v0.1.1")
));
assert!(!changelog_already_cut(
"# Changelog\n\n## [Unreleased]\n\nold\n",
None
));
}
#[test]
fn a_file_with_no_sections_yet_is_not_already_cut() {
assert!(!changelog_already_cut("", None));
assert!(!changelog_already_cut("# Changelog\n", Some("v0.1.0")));
}
#[test]
fn merge_replaces_unreleased_and_keeps_released() {
let existing =
"# Changelog\n\n## Unreleased (draft vs abc)\n\nold\n\n## 0.1.0\n\n- shipped\n";
let merged = merge_changelog(existing, "## Unreleased (draft vs def)\n\nnew");
assert!(merged.contains("draft vs def"));
assert!(!merged.contains("old"));
assert!(merged.contains("## 0.1.0"));
assert!(merged.contains("- shipped"));
assert_eq!(
merged,
merge_changelog(&merged, "## Unreleased (draft vs def)\n\nnew")
);
}
#[test]
fn merge_into_empty_file() {
let merged = merge_changelog("", "## Unreleased (draft vs abc)\n\nnew");
assert!(merged.starts_with("# Changelog\n\n## Unreleased"));
}
#[test]
fn merge_respects_keep_a_changelog_files() {
let existing = "# Changelog\n\nAll notable changes are documented here.\n\n\
## [Unreleased]\n\nstale\n\n## [2.8.0] - 2026-07-10\n\n- shipped\n";
let merged = merge_changelog(existing, "## Unreleased (draft vs v2.8.0)\n\nnew");
assert!(merged.contains("All notable changes are documented here."));
assert!(!merged.contains("stale"));
assert!(!merged.contains("## [Unreleased]"));
assert!(merged.contains("## [2.8.0] - 2026-07-10"));
assert_eq!(
merged,
merge_changelog(&merged, "## Unreleased (draft vs v2.8.0)\n\nnew")
);
}
#[test]
fn hand_written_notes_survive_regeneration() {
let existing = "# Changelog\n\n## Unreleased (draft vs abc)\n\n\
<!-- soothfast:notes -->\nDomain handles gain chart/history.\n\
<!-- /soothfast:notes -->\n\n### API surface\n\nold\n";
let draft = "## Unreleased (draft vs def)\n\n### API surface\n\nnew";
let merged = merge_changelog(existing, draft);
assert!(merged.contains("Domain handles gain chart/history."));
assert!(merged.contains("### API surface\n\nnew"));
assert!(merged.contains("<!-- /soothfast:notes -->\n\n### API surface"));
let notes_pos = merged.find("Domain handles").unwrap();
let surface_pos = merged.find("### API surface").unwrap();
assert!(notes_pos < surface_pos);
assert_eq!(merged, merge_changelog(&merged, draft));
}
#[test]
fn a_section_with_no_notes_yet_gets_the_scaffold() {
let existing = "# Changelog\n\n## Unreleased (draft vs abc)\n\nold\n";
let merged = merge_changelog(existing, "## Unreleased (draft vs def)\n\nnew");
assert!(!merged.contains("old"), "{merged}");
assert!(merged.contains("new"), "{merged}");
assert!(merged.contains("<!-- ### Overview -->"), "{merged}");
assert!(merged.contains("<!-- ### Upgrade notes -->"), "{merged}");
let notes = merged.find("<!-- soothfast:notes -->").unwrap();
assert!(notes < merged.find("new").unwrap(), "{merged}");
}
#[test]
fn the_scaffold_is_not_written_over_prose_that_exists() {
let existing = "# Changelog\n\n## Unreleased (draft vs abc)\n\n\
<!-- soothfast:notes -->\n### Overview\n\nwhat shipped\n\
<!-- /soothfast:notes -->\n\nold\n";
let merged = merge_changelog(existing, "## Unreleased (draft vs def)\n\nnew");
assert!(merged.contains("what shipped"), "{merged}");
assert!(!merged.contains("<!-- ### Overview -->"), "{merged}");
}
#[test]
fn unclosed_notes_marker_is_treated_as_absent() {
let existing = "# Changelog\n\n## Unreleased (draft vs abc)\n\n\
<!-- soothfast:notes -->\nnever closed\n";
let merged = merge_changelog(existing, "## Unreleased (draft vs def)\n\nnew");
assert!(!merged.contains("never closed"));
assert!(merged.contains("new"));
}
#[test]
fn an_explicit_features_flag_beats_both_tables() {
let got = resolve_features(Some("cli".into()), Some("changelog"), Some("gate"));
assert_eq!(got.as_deref(), Some("cli"));
}
#[test]
fn changelog_features_beat_gate_features() {
let got = resolve_features(None, Some("changelog"), Some("gate"));
assert_eq!(got.as_deref(), Some("changelog"));
}
#[test]
fn gate_features_fill_in_when_nothing_else_does() {
assert_eq!(
resolve_features(None, None, Some("gate")).as_deref(),
Some("gate")
);
assert_eq!(resolve_features(None, None, None), None);
}
#[test]
fn the_bot_s_own_regeneration_commits_are_left_out() {
let log = "soothfast-bot[bot]\u{1f}chore: regenerate soothfast outputs (#474)\n\
Harvey Tseng\u{1f}feat: read gate features from soothfast.toml (#475)\n";
assert_eq!(
subjects_excluding_author(log, DEFAULT_BOT_AUTHOR),
["feat: read gate features from soothfast.toml (#475)"]
);
}
#[test]
fn dependabot_commits_survive_the_filter() {
let log = "dependabot[bot]\u{1f}chore(deps): bump serde from 1.0.2 to 1.0.3 (#476)\n\
soothfast-bot[bot]\u{1f}docs: regenerate CHANGELOG.md (#477)\n";
assert_eq!(
subjects_excluding_author(log, DEFAULT_BOT_AUTHOR),
["chore(deps): bump serde from 1.0.2 to 1.0.3 (#476)"]
);
}
#[test]
fn a_renamed_bot_author_filters_in_place_of_the_default() {
let log = "acme-bot[bot]\u{1f}chore: regenerate soothfast outputs (#12)\n\
soothfast-bot[bot]\u{1f}feat: ship a thing (#13)\n";
assert_eq!(
subjects_excluding_author(log, "acme-bot[bot]"),
["feat: ship a thing (#13)"]
);
}
#[test]
fn an_explicit_bot_author_flag_beats_the_table() {
assert_eq!(
resolve_bot_author(Some("cli[bot]".into()), Some("toml[bot]")),
"cli[bot]"
);
}
#[test]
fn the_table_fills_in_for_a_repo_that_renamed_the_slug() {
assert_eq!(resolve_bot_author(None, Some("toml[bot]")), "toml[bot]");
}
#[test]
fn the_shipped_slug_is_the_last_resort() {
assert_eq!(resolve_bot_author(None, None), DEFAULT_BOT_AUTHOR);
}
#[test]
fn a_subject_the_bot_did_not_write_is_kept_verbatim() {
let log = "Harvey Tseng\u{1f}fix(server): drop a cache (#478)\n";
assert_eq!(
subjects_excluding_author(log, DEFAULT_BOT_AUTHOR),
["fix(server): drop a cache (#478)"]
);
}
}