cargo-soothfast 0.1.1

CLI for soothfast: measure, gate, docs, spec, report
//! `cargo soothfast report render|changelog` — doc fragments produced from
//! recorded data: perf tables, trend charts, badges, llms.txt, and a living
//! CHANGELOG.md whose Unreleased section is regenerated in place.

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 {
    // Repeatable: `report render` accepts multiple `-p PKG` to combine
    // several crates' surfaces into one llms.txt (a facade crate's own
    // surface is typically too thin on its own — see llms.rs docs).
    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));

    // Trend charts from the series, when it exists.
    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![{name} trend]({file})\n"));
                    println!("report: wrote {}", out_dir.join(&file).display());
                }
            }
        }
    }

    // Badge reflects the last recorded gate verdict, never a hardcoded pass.
    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());
    }

    // llms.txt needs the public surface (nightly rustdoc); skip with a note
    // when no package is given. Multiple -p flags combine into one digest —
    // a single facade crate's own surface is typically too thin alone.
    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
}

/// One diff per package, headed by the crate name when there is more than
/// one — an unattributed `ADDED foo::bar` says nothing about where it landed.
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),
    };

    // Without a reference there is no earlier surface to diff against, so
    // the release is described by what it contains. A facade crate's own
    // surface is thin; pass every crate whose API ships.
    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, // ref-side measurements: Phase 5 wiring
        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
}

/// Replace the previous Unreleased section with the fresh draft, keeping
/// released sections — regeneration is idempotent, one Unreleased at a time.
/// Keep-a-Changelog files are respected: the preamble prose before the first
/// section survives, and a bracketed `## [Unreleased]` heading counts as the
/// Unreleased section to replace.
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 {
            // The title is re-emitted below; keep any other preamble prose.
            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"));
        // Idempotent: merging the same draft again changes nothing.
        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");
        // Preamble prose survives; the bracketed Unreleased section is replaced.
        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")
        );
    }
}