use std::path::PathBuf;
use serde_json::Value;
use soothfast_report::{badges, changelog, llms, perf_table, trend_chart};
use crate::{docs_support, invoke};
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 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 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 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: None, new_baseline: &baseline,
});
let root = match invoke::workspace_root() {
Ok(r) => r,
Err(e) => return err(&e.to_string()),
};
let path = a.out.unwrap_or_else(|| root.join("CHANGELOG.md"));
let existing = std::fs::read_to_string(&path).unwrap_or_default();
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 merge_changelog(existing: &str, draft: &str) -> String {
let mut preamble = String::new();
let mut released = 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');
}
}
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');
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::merge_changelog;
#[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")
);
}
}