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>,
}
fn parse(args: &[String]) -> Result<ReportArgs, String> {
let mut a = ReportArgs {
pkg: Vec::new(),
baseline: "base".into(),
out: None,
against_ref: None,
features: 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(),
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) in trend_chart::METRICS {
if let Some(svg) = trend_chart::render(&points, key, name) {
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 a = match parse(args) {
Ok(a) => a,
Err(e) => return err(&e),
};
if a.pkg.is_empty() {
return err("report changelog needs -p PKG");
}
let root = match invoke::workspace_root() {
Ok(r) => r,
Err(e) => return err(&e.to_string()),
};
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 text = changelog::draft(&changelog::DraftInputs {
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 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("");
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 -->";
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) {
Some(notes) if !notes.is_empty() => {
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'));
}
_ => doc.push_str(draft),
}
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::{changelog_already_cut, merge_changelog};
#[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")
));
}
#[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 no_notes_marker_behaves_exactly_as_before() {
let existing = "# Changelog\n\n## Unreleased (draft vs abc)\n\nold\n";
let draft = "## Unreleased (draft vs def)\n\nnew";
assert_eq!(
merge_changelog(existing, draft),
format!("# Changelog\n\n{draft}\n")
);
}
#[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"));
}
}