soothfast-report 0.1.3

Soothfast report engine: perf tables, trend charts, changelog drafts, badges, llms.txt
Documentation
//! `llms.txt`: a compact machine-readable digest of the public surface and
//! its measured costs, for AI agents. Guaranteed current because it is
//! derived — the answer to "agents reading stale READMEs as truth".

use std::collections::BTreeMap;

use serde_json::Value;

/// One public item as fed by the CLI (from the docs-engine surface).
pub struct SurfaceEntry {
    pub path: String,
    pub kind: String,
    pub signature: String,
    /// Name of the crate this item was extracted from (groups multi-crate
    /// digests; report.rs concatenates one `surface_entries()` call per
    /// `-p PKG` flag into a single entry list).
    pub pkg: String,
    /// First line of the doc comment — compact, for search hits and lists.
    pub summary: String,
    /// Full doc comment (all paragraphs, code fences intact) — the whole
    /// point of `llms.txt` over a bare signature list: an agent gets the
    /// actual usage explanation, not just a name to guess at.
    pub docs: String,
}

/// Render `llms.txt`: every public item grouped by crate, with its full
/// doc comment and measured facts attributed via `covers` (or direct id)
/// matching.
pub fn render(pkg: &str, entries: &[SurfaceEntry], baseline: &Value) -> String {
    // Measurements attribute to public items via `covers` (or direct id).
    // These numbers are point-in-time and will drift slightly between any
    // two measurement runs (even perfcnt/alloc, not just walltime) — a
    // committed llms.txt's drift gate (see the Makefile's `llms-gate`)
    // deliberately ignores `**Measured:**` lines rather than pretend these
    // are reproducible; regression detection is `make gate`'s job.
    let mut measured: BTreeMap<String, String> = BTreeMap::new();
    if let Some(items) = baseline["items"].as_object() {
        for (id, m) in items {
            let target = m["covers"].as_str().filter(|c| !c.is_empty()).unwrap_or(id);
            let mut facts = Vec::new();
            if let Some(i) = m["perfcnt"]["instructions"].as_u64() {
                facts.push(format!("{i} instructions/iter"));
            }
            if let Some(ns) = m["walltime"]["median_ns"].as_f64() {
                facts.push(format!("{ns:.0}ns median"));
            }
            if let Some(a) = m["alloc"]["allocs"].as_u64() {
                facts.push(format!("{a} allocs/iter"));
            }
            if let Some(p) = m["asyncexec"]["polls"].as_u64() {
                facts.push(format!("{p} polls/iter"));
            }
            if !facts.is_empty() {
                measured.insert(target.to_string(), facts.join(", "));
            }
        }
    }

    // Group by source crate, preserving each crate's own path order.
    let mut groups: BTreeMap<&str, Vec<&SurfaceEntry>> = BTreeMap::new();
    for e in entries {
        if e.kind == "module" {
            continue;
        }
        groups.entry(e.pkg.as_str()).or_default().push(e);
    }
    // A crate-name heading is only informative once there's more than one
    // crate in the digest — a single-crate render already says so in the H1.
    let multi_crate = groups.len() > 1;
    let item_level = if multi_crate { "####" } else { "###" };

    let mut out = format!(
        "# {pkg}\n\n> Generated by `cargo soothfast report`, derived from the current code and\n> measurements rather than hand-written. Trust it over any prose.\n\n## Public API\n\n"
    );
    for (crate_name, items) in &groups {
        if multi_crate {
            out.push_str(&format!("### {crate_name}\n\n"));
        }
        for e in items {
            let head = if e.signature.is_empty() {
                e.path.as_str()
            } else {
                e.signature.as_str()
            };
            out.push_str(&format!("{item_level} `{head}`\n\n"));
            let doc = e.docs.trim();
            if !doc.is_empty() {
                out.push_str(&nest_headings(doc, item_level.len()));
                out.push_str("\n\n");
            }
            if let Some(facts) = measured.get(&e.path) {
                out.push_str(&format!("**Measured:** {facts}\n\n"));
            }
        }
    }
    out
}

/// Rustdoc conventions (`# Panics`, `# Examples`, ...) write ATX headings
/// starting at depth 1, since a doc comment has no enclosing document. Once
/// spliced under an item heading here, those need to nest *below* it rather
/// than jump back to top level — shift every heading outside a fenced code
/// block down by `item_depth`, capped at markdown's depth-6 ceiling.
fn nest_headings(text: &str, item_depth: usize) -> String {
    let mut in_fence = false;
    text.lines()
        .map(|line| {
            if line.trim_start().starts_with("```") {
                in_fence = !in_fence;
                return line.to_string();
            }
            if in_fence {
                return line.to_string();
            }
            let hashes = line.chars().take_while(|&c| c == '#').count();
            if hashes > 0 && hashes <= 6 && line.as_bytes().get(hashes) == Some(&b' ') {
                let depth = (item_depth + hashes).min(6);
                format!("{}{}", "#".repeat(depth), &line[hashes..])
            } else {
                line.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn entry(pkg: &str, path: &str, docs: &str) -> SurfaceEntry {
        SurfaceEntry {
            path: path.to_string(),
            kind: "function".to_string(),
            signature: format!("pub fn {path}()"),
            pkg: pkg.to_string(),
            summary: docs.lines().next().unwrap_or("").to_string(),
            docs: docs.to_string(),
        }
    }

    #[test]
    fn nest_headings_shifts_rustdoc_sections_below_the_item_heading() {
        let doc = "Does a thing.\n\n# Panics\n\nIf you misuse it.";
        assert_eq!(
            nest_headings(doc, 4),
            "Does a thing.\n\n##### Panics\n\nIf you misuse it."
        );
    }

    #[test]
    fn nest_headings_ignores_hashes_inside_fenced_code() {
        let doc = "Usage:\n\n```ignore\n# not a heading\n#[attr]\nfn f() {}\n```\n\n# Examples";
        let shifted = nest_headings(doc, 3);
        assert!(shifted.contains("```ignore\n# not a heading\n#[attr]"));
        assert!(shifted.contains("#### Examples"));
    }

    #[test]
    fn nest_headings_caps_at_depth_six() {
        assert_eq!(nest_headings("###### already max", 4), "###### already max");
    }

    #[test]
    fn render_groups_multiple_crates_under_their_own_heading() {
        let entries = vec![
            entry("soothfast", "soothfast::keep", "Optimizer barrier."),
            entry(
                "soothfast-macros",
                "soothfast_macros::bench",
                "Register a bench.",
            ),
        ];
        let out = render("soothfast, soothfast-macros", &entries, &json!({}));
        assert!(out.contains("### soothfast\n"));
        assert!(out.contains("### soothfast-macros\n"));
        assert!(out.contains("#### `pub fn soothfast::keep()`"));
    }

    #[test]
    fn render_single_crate_omits_the_redundant_crate_heading() {
        let entries = vec![entry("soothfast", "soothfast::keep", "Optimizer barrier.")];
        let out = render("soothfast", &entries, &json!({}));
        assert!(!out.contains("### soothfast\n"));
        assert!(out.contains("### `pub fn soothfast::keep()`"));
    }

    #[test]
    fn render_attaches_measured_facts_by_covers_or_id() {
        let entries = vec![entry("soothfast", "soothfast::keep", "Optimizer barrier.")];
        let baseline = json!({
            "items": {
                "soothfast::keep": {
                    "perfcnt": { "instructions": 42 },
                    "walltime": { "median_ns": 12.0 },
                }
            }
        });
        let out = render("soothfast", &entries, &baseline);
        assert!(out.contains("**Measured:** 42 instructions/iter, 12ns median"));
    }

    #[test]
    fn render_skips_module_kind_entries() {
        let mut e = entry("soothfast", "soothfast::registry", "");
        e.kind = "module".to_string();
        let out = render("soothfast", &[e], &json!({}));
        assert!(!out.contains("registry"));
    }
}