Skip to main content

cargo_impact/
format.rs

1//! Output format dispatch: text, markdown, and JSON.
2//!
3//! The JSON envelope matches the shape documented in README §8 so agents
4//! calling the future MCP server (v0.3) get identical structure from the
5//! CLI today. Markdown output is designed to be pasted directly into an AI
6//! chat window — it leads with a summary and lists findings by severity.
7
8use crate::finding::{Finding, FindingKind, SeverityClass, TierSummary};
9use clap::ValueEnum;
10use serde::Serialize;
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Copy, ValueEnum, Default)]
15#[value(rename_all = "kebab-case")]
16pub enum Format {
17    #[default]
18    Text,
19    Markdown,
20    Json,
21    /// SARIF v2.1.0 — the format GitHub code scanning, GitLab, Sonar,
22    /// and every major scanner UI consumes. Emit this from CI and
23    /// upload via `github/codeql-action/upload-sarif` (or equivalent)
24    /// to get inline-on-PR-diff annotations for free.
25    Sarif,
26    /// Markdown optimized for GitHub PR comments — collapsed
27    /// `<details>` per severity, compact tables instead of bullets,
28    /// no verification checklist. Pair with an action that posts
29    /// the output as a sticky PR comment.
30    PrComment,
31}
32
33/// Top-level JSON envelope. Stable across releases; additions go at the end.
34#[derive(Debug, Serialize)]
35pub struct Report<'a> {
36    pub version: &'static str,
37    pub changed_files: &'a [PathBuf],
38    pub candidate_symbols: &'a [String],
39    pub findings: &'a [Finding],
40    pub summary: ReportSummary,
41}
42
43#[derive(Debug, Serialize)]
44pub struct ReportSummary {
45    pub total: usize,
46    pub by_severity: BTreeMap<String, u32>,
47    pub by_tier: TierSummary,
48}
49
50impl ReportSummary {
51    pub fn build(findings: &[Finding]) -> Self {
52        let mut by_severity: BTreeMap<String, u32> = BTreeMap::new();
53        for f in findings {
54            *by_severity
55                .entry(f.severity.as_label().to_lowercase())
56                .or_insert(0) += 1;
57        }
58        Self {
59            total: findings.len(),
60            by_severity,
61            by_tier: TierSummary::from_findings(findings),
62        }
63    }
64}
65
66pub fn render(
67    format: Format,
68    changed_files: &[PathBuf],
69    candidate_symbols: &[String],
70    findings: &[Finding],
71) -> anyhow::Result<String> {
72    render_with_budget(format, changed_files, candidate_symbols, findings, 0)
73}
74
75/// Like [`render`] but with a character budget applied to the markdown
76/// format. `0` = unlimited (matches the `render` wrapper above). Text
77/// and JSON ignore the budget — text is for terminal humans who can
78/// scroll; JSON is for programmatic consumers who can filter.
79pub fn render_with_budget(
80    format: Format,
81    changed_files: &[PathBuf],
82    candidate_symbols: &[String],
83    findings: &[Finding],
84    budget: usize,
85) -> anyhow::Result<String> {
86    match format {
87        Format::Text => Ok(render_text(changed_files, candidate_symbols, findings)),
88        Format::Markdown => Ok(render_markdown(
89            changed_files,
90            candidate_symbols,
91            findings,
92            budget,
93        )),
94        Format::Json => render_json(changed_files, candidate_symbols, findings),
95        Format::Sarif => render_sarif(findings),
96        Format::PrComment => Ok(render_pr_comment(
97            changed_files,
98            candidate_symbols,
99            findings,
100            budget,
101        )),
102    }
103}
104
105fn render_text(
106    changed_files: &[PathBuf],
107    candidate_symbols: &[String],
108    findings: &[Finding],
109) -> String {
110    let mut out = String::new();
111    out.push_str(&format!("cargo-impact v{}\n\n", env!("CARGO_PKG_VERSION")));
112
113    out.push_str(&format!("Changed files ({}):\n", changed_files.len()));
114    for f in changed_files {
115        out.push_str(&format!("  {}\n", f.display()));
116    }
117    out.push('\n');
118
119    out.push_str(&format!(
120        "Candidate symbols ({}):\n",
121        candidate_symbols.len()
122    ));
123    for s in candidate_symbols {
124        out.push_str(&format!("  {s}\n"));
125    }
126    out.push('\n');
127
128    let grouped = group_by_severity(findings);
129    for severity in [
130        SeverityClass::High,
131        SeverityClass::Medium,
132        SeverityClass::Low,
133        SeverityClass::Unknown,
134    ] {
135        let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
136        out.push_str(&format!(
137            "{icon} {label} ({n})\n",
138            icon = severity.icon(),
139            label = severity.as_label(),
140            n = group.len()
141        ));
142        for f in group {
143            out.push_str(&format!(
144                "  [{id}] {summary} · {tier:?} {conf:.2}\n",
145                id = f.id,
146                summary = finding_summary(f),
147                tier = f.tier,
148                conf = f.confidence,
149            ));
150        }
151        out.push('\n');
152    }
153
154    out
155}
156
157fn render_markdown(
158    changed_files: &[PathBuf],
159    candidate_symbols: &[String],
160    findings: &[Finding],
161    budget: usize,
162) -> String {
163    let mut out = String::new();
164    render_markdown_header(&mut out, changed_files, candidate_symbols, findings);
165
166    // Findings arrive pre-sorted by (severity, tier, kind, evidence, id),
167    // so emitting them in order already implements our "priority first"
168    // truncation policy — we just stop writing when the budget would be
169    // exceeded. Tracking the pre-sections offset keeps rendered sections
170    // accounted for separately from the header so we never drop the
171    // summary even under a tiny budget.
172    let unlimited = budget == 0;
173    let mut omitted_findings = 0usize;
174    let mut omitted_checklist = 0usize;
175
176    let grouped = group_by_severity(findings);
177    for severity in [
178        SeverityClass::High,
179        SeverityClass::Medium,
180        SeverityClass::Low,
181        SeverityClass::Unknown,
182    ] {
183        let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
184        if group.is_empty() {
185            continue;
186        }
187        let header = format!(
188            "## {icon} {label} ({n})\n\n",
189            icon = severity.icon(),
190            label = severity.as_label(),
191            n = group.len()
192        );
193        // Only commit the section header if at least one finding will fit
194        // under it — otherwise we'd emit an orphan "## HIGH (3)" with no
195        // bullets, which looks broken.
196        let mut header_written = false;
197        for f in group {
198            let body = render_finding_bullet(f);
199            if !header_written {
200                if !unlimited && out.len() + header.len() + body.len() > budget {
201                    omitted_findings += 1;
202                    continue;
203                }
204                out.push_str(&header);
205                header_written = true;
206            }
207            if !unlimited && out.len() + body.len() > budget {
208                omitted_findings += 1;
209                continue;
210            }
211            out.push_str(&body);
212        }
213        if header_written {
214            out.push('\n');
215        }
216    }
217
218    let checklist_header = "## Verification checklist\n\n";
219    if findings.is_empty() {
220        out.push_str(checklist_header);
221        out.push_str("_No findings — nothing to verify._\n");
222    } else {
223        let mut header_written = false;
224        for f in findings {
225            let body = render_checklist_line(f);
226            if !header_written {
227                if !unlimited && out.len() + checklist_header.len() + body.len() > budget {
228                    omitted_checklist += 1;
229                    continue;
230                }
231                out.push_str(checklist_header);
232                header_written = true;
233            }
234            if !unlimited && out.len() + body.len() > budget {
235                omitted_checklist += 1;
236                continue;
237            }
238            out.push_str(&body);
239        }
240    }
241
242    if !unlimited && (omitted_findings > 0 || omitted_checklist > 0) {
243        out.push_str(&format!(
244            "\n---\n\n> **Budget truncation:** {omitted_findings} findings and \
245             {omitted_checklist} checklist items omitted because the rendered \
246             markdown would exceed the `--budget={budget}` character limit. \
247             Priority order was severity → tier → confidence, so what you see \
248             is what matters most. Re-run with `--budget=0` or `--format=json` \
249             to get everything.\n"
250        ));
251    }
252
253    out
254}
255
256fn render_markdown_header(
257    out: &mut String,
258    changed_files: &[PathBuf],
259    candidate_symbols: &[String],
260    findings: &[Finding],
261) {
262    out.push_str(&format!(
263        "# cargo-impact v{} blast radius\n\n",
264        env!("CARGO_PKG_VERSION")
265    ));
266    let summary = ReportSummary::build(findings);
267    out.push_str("## Summary\n\n");
268    out.push_str(&format!(
269        "- **Changed files:** {}\n- **Candidate symbols:** {}\n- **Findings:** {} \
270         ({} high, {} medium, {} low, {} unknown)\n\n",
271        changed_files.len(),
272        candidate_symbols.len(),
273        summary.total,
274        summary.by_severity.get("high").unwrap_or(&0),
275        summary.by_severity.get("medium").unwrap_or(&0),
276        summary.by_severity.get("low").unwrap_or(&0),
277        summary.by_severity.get("unknown").unwrap_or(&0),
278    ));
279}
280
281fn render_finding_bullet(f: &Finding) -> String {
282    let mut body = format!(
283        "- **[{id}]** {summary} — *{tier:?} {conf:.2}* — {evidence}\n",
284        id = f.id,
285        summary = finding_summary(f),
286        tier = f.tier,
287        conf = f.confidence,
288        evidence = f.evidence,
289    );
290    if let Some(action) = &f.suggested_action {
291        body.push_str(&format!("  - Suggested: `{action}`\n"));
292    }
293    body
294}
295
296fn render_checklist_line(f: &Finding) -> String {
297    format!(
298        "- [ ] **{label}** {summary} — *{tier:?} {conf:.2}*\n",
299        label = f.severity.as_label(),
300        summary = finding_summary(f),
301        tier = f.tier,
302        conf = f.confidence,
303    )
304}
305
306fn render_json(
307    changed_files: &[PathBuf],
308    candidate_symbols: &[String],
309    findings: &[Finding],
310) -> anyhow::Result<String> {
311    let report = Report {
312        version: env!("CARGO_PKG_VERSION"),
313        changed_files,
314        candidate_symbols,
315        findings,
316        summary: ReportSummary::build(findings),
317    };
318    Ok(serde_json::to_string_pretty(&report)?)
319}
320
321// ---------------------------------------------------------------------------
322// SARIF v2.1.0 — inherits every major scanner UI's "annotations inline on
323// PR diff" rendering via `github/codeql-action/upload-sarif` and friends.
324// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/
325//
326// Design notes
327// ------------
328// * `tool.driver.rules[]` enumerates every `FindingKind` tag so scanners
329//   can hyperlink each result back to a rule description.
330// * `level` maps from severity: High→error, Medium→warning, Low→note,
331//   Unknown→none. Tier/confidence/severity also live in `properties` so
332//   consumers that care about our richer tiering can still access it.
333// * `partialFingerprints.primaryLocationLineHash` reuses our content-
334//   hashed finding id. Scanners dedupe using fingerprints, so the
335//   same finding across runs collapses into one tracked issue.
336// * Paths forward-slash-normalized to keep Windows + Unix results
337//   comparable under the same SARIF upload.
338
339fn render_sarif(findings: &[Finding]) -> anyhow::Result<String> {
340    let rules = FindingKind::all_tags()
341        .iter()
342        .map(|tag| sarif_rule(tag))
343        .collect::<Vec<_>>();
344
345    let results = findings.iter().map(sarif_result).collect::<Vec<_>>();
346
347    let doc = serde_json::json!({
348        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
349        "version": "2.1.0",
350        "runs": [
351            {
352                "tool": {
353                    "driver": {
354                        "name": "cargo-impact",
355                        "version": env!("CARGO_PKG_VERSION"),
356                        "informationUri": "https://github.com/asmuelle/cargo-impact",
357                        "rules": rules,
358                    }
359                },
360                "results": results,
361            }
362        ]
363    });
364    Ok(serde_json::to_string_pretty(&doc)?)
365}
366
367fn sarif_rule(tag: &str) -> serde_json::Value {
368    serde_json::json!({
369        "id": tag,
370        "name": sarif_rule_name(tag),
371        "shortDescription": { "text": sarif_rule_short(tag) },
372        "helpUri": "https://github.com/asmuelle/cargo-impact#README",
373    })
374}
375
376fn sarif_rule_name(tag: &str) -> String {
377    // `camelCase` name per SARIF convention; scanner UIs sometimes
378    // display `name` instead of `id`.
379    tag.split('_')
380        .enumerate()
381        .map(|(i, part)| {
382            if i == 0 {
383                part.to_string()
384            } else {
385                let mut chars = part.chars();
386                match chars.next() {
387                    Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
388                    None => String::new(),
389                }
390            }
391        })
392        .collect()
393}
394
395fn sarif_rule_short(tag: &str) -> &'static str {
396    match tag {
397        "test_reference" => "Test function references a changed symbol",
398        "trait_impl" => "impl of a changed trait",
399        "derived_trait_impl" => "#[derive(…)] of a changed trait",
400        "dyn_dispatch" => "dyn Trait use of a changed trait",
401        "doc_drift_link" => "Intra-doc link to a changed symbol",
402        "doc_drift_keyword" => "Bare mention of a changed symbol in prose",
403        "ffi_signature_change" => "FFI signature added/removed/modified",
404        "build_script_changed" => "build.rs modified",
405        "semver_check" => "cargo-semver-checks reports public API change",
406        "trait_definition_change" => "Method added/removed/changed on a trait",
407        "resolved_reference" => "rust-analyzer-resolved reference to a changed symbol",
408        "runtime_surface" => "Framework runtime surface (axum route, clap command) affected",
409        _ => "cargo-impact finding",
410    }
411}
412
413fn sarif_result(f: &Finding) -> serde_json::Value {
414    let mut location = serde_json::json!({});
415    if let Some(path) = f.primary_path() {
416        let uri = path.to_string_lossy().replace('\\', "/");
417        let mut physical = serde_json::json!({
418            "artifactLocation": { "uri": uri },
419        });
420        if let Some(line) = finding_line(f) {
421            physical["region"] = serde_json::json!({ "startLine": line });
422        }
423        location = serde_json::json!({ "physicalLocation": physical });
424    }
425
426    let mut locations = Vec::new();
427    if !location.as_object().is_none_or(serde_json::Map::is_empty) {
428        locations.push(location);
429    }
430
431    serde_json::json!({
432        "ruleId": f.kind.tag(),
433        "level": sarif_level(f.severity),
434        "message": { "text": f.evidence },
435        "locations": locations,
436        "partialFingerprints": {
437            "primaryLocationLineHash": f.id
438        },
439        "properties": {
440            "tier": format!("{:?}", f.tier).to_lowercase(),
441            "confidence": f.confidence,
442            "severity": f.severity.as_label().to_lowercase(),
443            "suggestedAction": f.suggested_action,
444        }
445    })
446}
447
448fn sarif_level(severity: SeverityClass) -> &'static str {
449    // SARIF levels: error | warning | note | none.
450    match severity {
451        SeverityClass::High => "error",
452        SeverityClass::Medium => "warning",
453        SeverityClass::Low => "note",
454        SeverityClass::Unknown => "none",
455    }
456}
457
458/// Extract a line number when the finding carries one. Only doc-drift
459/// findings have a line; everything else leaves `region` unset and
460/// SARIF consumers fall back to file-level annotation.
461fn finding_line(f: &Finding) -> Option<u32> {
462    match &f.kind {
463        FindingKind::DocDriftLink { line, .. } | FindingKind::DocDriftKeyword { line, .. } => {
464            Some(*line)
465        }
466        _ => None,
467    }
468}
469
470// ---------------------------------------------------------------------------
471// PR-comment markdown — tuned for posting as a sticky comment on a GitHub
472// PR. Drops the verification checklist (reviewers don't tick boxes from
473// the diff view), uses collapsed `<details>` per severity so a scrollable
474// body degrades gracefully, tables instead of bullets for density.
475
476fn render_pr_comment(
477    changed_files: &[PathBuf],
478    candidate_symbols: &[String],
479    findings: &[Finding],
480    budget: usize,
481) -> String {
482    let summary = ReportSummary::build(findings);
483    let unlimited = budget == 0;
484    let mut out = String::new();
485
486    // One-line header with severity-badge counts, always emits so the
487    // comment is never empty even under the tightest budget.
488    out.push_str(&format!(
489        "### 🎯 cargo-impact — {total} findings across {nfiles} file{s}\n\
490         `🔴 {h} · 🟡 {m} · 🔵 {l} · ⚪ {u}`\n\n",
491        total = summary.total,
492        nfiles = changed_files.len(),
493        s = if changed_files.len() == 1 { "" } else { "s" },
494        h = summary.by_severity.get("high").unwrap_or(&0),
495        m = summary.by_severity.get("medium").unwrap_or(&0),
496        l = summary.by_severity.get("low").unwrap_or(&0),
497        u = summary.by_severity.get("unknown").unwrap_or(&0),
498    ));
499
500    if findings.is_empty() {
501        out.push_str("_No findings — nothing to verify._\n");
502        return out;
503    }
504
505    // Expand the HIGH section by default so the important stuff is
506    // immediately visible; collapse the rest. Reviewers who care about
507    // MEDIUM/LOW/UNKNOWN can click in.
508    let grouped = group_by_severity(findings);
509    let mut omitted = 0usize;
510    for (severity, expand_default) in [
511        (SeverityClass::High, true),
512        (SeverityClass::Medium, false),
513        (SeverityClass::Low, false),
514        (SeverityClass::Unknown, false),
515    ] {
516        let group = grouped.get(&severity).map_or(&[][..], |v| v.as_slice());
517        if group.is_empty() {
518            continue;
519        }
520        let open = if expand_default { " open" } else { "" };
521        let block_header = format!(
522            "<details{open}><summary>{icon} {label} ({n})</summary>\n\n\
523             | Kind | Location | Evidence |\n\
524             |---|---|---|\n",
525            icon = severity.icon(),
526            label = severity.as_label(),
527            n = group.len(),
528        );
529        let mut rows = String::new();
530        for f in group {
531            let row = format!(
532                "| `{kind}` | {loc} | {evidence} |\n",
533                kind = f.kind.tag(),
534                loc = pr_comment_location(f),
535                evidence = escape_pipe(&f.evidence),
536            );
537            if !unlimited && out.len() + block_header.len() + rows.len() + row.len() > budget {
538                omitted += 1;
539                continue;
540            }
541            rows.push_str(&row);
542        }
543        if !rows.is_empty() {
544            out.push_str(&block_header);
545            out.push_str(&rows);
546            out.push_str("\n</details>\n\n");
547        }
548    }
549
550    // Context + candidate symbols collapsed into one block — useful
551    // context, but not what the reviewer needs to see first.
552    out.push_str(&format!(
553        "<details><summary>📁 {n} changed file{s} · {k} candidate symbol{ks}</summary>\n\n",
554        n = changed_files.len(),
555        s = if changed_files.len() == 1 { "" } else { "s" },
556        k = candidate_symbols.len(),
557        ks = if candidate_symbols.len() == 1 {
558            ""
559        } else {
560            "s"
561        },
562    ));
563    out.push_str("**Changed files:**\n\n");
564    for f in changed_files {
565        out.push_str(&format!("- `{}`\n", f.display()));
566    }
567    if !candidate_symbols.is_empty() {
568        out.push_str("\n**Candidate symbols:** ");
569        out.push_str(
570            &candidate_symbols
571                .iter()
572                .map(|s| format!("`{s}`"))
573                .collect::<Vec<_>>()
574                .join(", "),
575        );
576        out.push('\n');
577    }
578    out.push_str("\n</details>\n");
579
580    if !unlimited && omitted > 0 {
581        out.push_str(&format!(
582            "\n> ⚠ {omitted} findings omitted to fit the `--budget={budget}` cap.\n"
583        ));
584    }
585
586    out.push_str(&format!(
587        "\n<sub>Generated by [cargo-impact v{}](https://github.com/asmuelle/cargo-impact) · [full report](#) · [raw JSON](#)</sub>\n",
588        env!("CARGO_PKG_VERSION")
589    ));
590
591    out
592}
593
594fn pr_comment_location(f: &Finding) -> String {
595    match f.primary_path() {
596        Some(p) => match finding_line(f) {
597            Some(line) => format!("`{}:{line}`", p.display()),
598            None => format!("`{}`", p.display()),
599        },
600        None => "—".to_string(),
601    }
602}
603
604fn escape_pipe(s: &str) -> String {
605    // Markdown tables use `|` as the column separator; evidence text
606    // may contain pipes that we need to escape.
607    s.replace('|', "\\|").replace('\n', " ")
608}
609
610fn group_by_severity(findings: &[Finding]) -> BTreeMap<SeverityClass, Vec<&Finding>> {
611    let mut out: BTreeMap<SeverityClass, Vec<&Finding>> = BTreeMap::new();
612    for f in findings {
613        out.entry(f.severity).or_default().push(f);
614    }
615    out
616}
617
618/// One-line human summary of a finding — renders across all output formats.
619fn finding_summary(f: &Finding) -> String {
620    match &f.kind {
621        FindingKind::TestReference {
622            test,
623            matched_symbols,
624        } => format!(
625            "test `{}` ({}) references {}",
626            test.symbol,
627            test.file.display(),
628            matched_symbols.join(", ")
629        ),
630        FindingKind::TraitImpl {
631            trait_name,
632            impl_for,
633            impl_site,
634        } => format!(
635            "impl `{trait_name}` for `{impl_for}` ({})",
636            impl_site.file.display()
637        ),
638        FindingKind::DerivedTraitImpl {
639            trait_name,
640            impl_for,
641            derive_site,
642        } => format!(
643            "`#[derive({trait_name})]` on `{impl_for}` ({})",
644            derive_site.file.display()
645        ),
646        FindingKind::DynDispatch { trait_name, site } => {
647            format!("`dyn {trait_name}` used in {}", site.file.display())
648        }
649        FindingKind::DocDriftLink { symbol, doc, line } => format!(
650            "intra-doc link to `{symbol}` in {}:{line}",
651            doc.file.display()
652        ),
653        FindingKind::DocDriftKeyword { symbol, doc, line } => {
654            format!("`{symbol}` mentioned in {}:{line}", doc.file.display())
655        }
656        FindingKind::FfiSignatureChange {
657            symbol,
658            file,
659            change,
660        } => format!("FFI `{symbol}` {change} in {}", file.display()),
661        FindingKind::BuildScriptChanged { file } => {
662            format!("`build.rs` changed ({})", file.display())
663        }
664        FindingKind::SemverCheck { level, .. } => {
665            format!("cargo-semver-checks reports `{level}` public-API change")
666        }
667        FindingKind::ResolvedReference {
668            source_symbol,
669            target,
670        } => format!(
671            "resolved reference: `{source_symbol}` used by `{}` in {}",
672            target.symbol,
673            target.file.display()
674        ),
675        FindingKind::RuntimeSurface {
676            framework,
677            identifier,
678            site,
679        } => format!(
680            "{framework} runtime surface `{identifier}` ({})",
681            site.file.display()
682        ),
683        FindingKind::TraitDefinitionChange {
684            trait_name,
685            method,
686            change,
687            file,
688        } => match method {
689            Some(m) => format!(
690                "trait `{trait_name}`: {} — `{m}` ({})",
691                change.phrase(),
692                file.display()
693            ),
694            None => format!(
695                "trait `{trait_name}`: {} ({})",
696                change.phrase(),
697                file.display()
698            ),
699        },
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::finding::{Finding, FindingKind, Location, Tier};
707    use std::path::PathBuf;
708
709    fn sample_finding(id: &str) -> Finding {
710        let kind = FindingKind::TestReference {
711            test: Location {
712                file: PathBuf::from("tests/smoke.rs"),
713                symbol: "smoke".into(),
714            },
715            matched_symbols: vec!["login".into()],
716        };
717        Finding::new(id, Tier::Likely, 0.85, kind, "direct ref")
718    }
719
720    #[test]
721    fn json_envelope_has_documented_fields() {
722        let findings = vec![sample_finding("f-0001")];
723        let out =
724            render_json(&[PathBuf::from("src/lib.rs")], &["login".into()], &findings).unwrap();
725        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
726        assert!(v["version"].is_string());
727        assert_eq!(v["changed_files"].as_array().unwrap().len(), 1);
728        assert_eq!(v["findings"].as_array().unwrap().len(), 1);
729        assert_eq!(v["summary"]["total"], 1);
730        assert_eq!(v["findings"][0]["kind"], "test_reference");
731    }
732
733    #[test]
734    fn markdown_renders_severity_sections_and_checklist() {
735        let findings = vec![sample_finding("f-0001")];
736        let md = render_markdown(
737            &[PathBuf::from("src/lib.rs")],
738            &["login".into()],
739            &findings,
740            0,
741        );
742        assert!(md.contains("# cargo-impact"));
743        assert!(md.contains("🟡 MEDIUM"));
744        assert!(md.contains("## Verification checklist"));
745        assert!(md.contains("- [ ]"));
746        assert!(md.contains("f-0001"));
747    }
748
749    #[test]
750    fn markdown_budget_zero_matches_unlimited_behavior() {
751        let findings = vec![
752            sample_finding("a"),
753            sample_finding("b"),
754            sample_finding("c"),
755        ];
756        let changed = [PathBuf::from("src/lib.rs")];
757        let symbols = ["login".into()];
758        let unlimited = render_markdown(&changed, &symbols, &findings, 0);
759        let also_unlimited = render_markdown(&changed, &symbols, &findings, usize::MAX);
760        assert_eq!(unlimited, also_unlimited);
761        assert!(!unlimited.contains("Budget truncation"));
762    }
763
764    #[test]
765    fn markdown_budget_truncates_and_emits_footer_with_accurate_counts() {
766        // 20 findings; a tight budget that fits the header+summary plus
767        // at most a handful of bullets. The renderer must stop emitting,
768        // then tell us *exactly* how many were dropped on each list so
769        // the agent can decide whether to re-request with a larger cap
770        // or switch to --format=json.
771        let findings: Vec<Finding> = (0..20)
772            .map(|i| sample_finding(&format!("f-{i:02}")))
773            .collect();
774        let md = render_markdown(
775            &[PathBuf::from("src/lib.rs")],
776            &["login".into()],
777            &findings,
778            1200,
779        );
780
781        // Header + summary always render — never drop them, even under a
782        // hostile budget.
783        assert!(md.contains("# cargo-impact"));
784        assert!(md.contains("## Summary"));
785        // Truncation footer names the limit and includes non-zero counts.
786        assert!(md.contains("Budget truncation"));
787        assert!(md.contains("--budget=1200"));
788        // Output itself stayed under the budget (we never exceed).
789        assert!(
790            md.len() <= 1200 + 600, // footer itself is allowed to exceed slightly
791            "rendered {} chars for budget 1200 — should stay close",
792            md.len()
793        );
794    }
795
796    #[test]
797    fn markdown_budget_preserves_highest_priority_findings() {
798        // Mix High + Medium + Low. The sort order in lib.rs is severity
799        // ascending (High first), so emitting top-down hits High before
800        // Low. Tiny budget should keep at least one HIGH finding.
801        let mk = |id: &str, sev: crate::finding::SeverityClass| {
802            let mut f = sample_finding(id);
803            f.severity = sev;
804            f
805        };
806        use crate::finding::SeverityClass as S;
807        let findings = vec![
808            mk("high-1", S::High),
809            mk("high-2", S::High),
810            mk("med-1", S::Medium),
811            mk("med-2", S::Medium),
812            mk("low-1", S::Low),
813        ];
814        let md = render_markdown(
815            &[PathBuf::from("src/lib.rs")],
816            &["login".into()],
817            &findings,
818            900,
819        );
820        assert!(
821            md.contains("high-1"),
822            "highest-priority finding must survive truncation"
823        );
824        // And the truncation message confirms omission happened.
825        assert!(md.contains("Budget truncation"));
826    }
827
828    #[test]
829    fn markdown_budget_header_always_emitted_even_if_it_alone_exceeds() {
830        // Pathological: budget smaller than the header. We still emit
831        // the header + summary (the renderer's contract is "shape stays
832        // intact"), then the truncation footer explains what happened.
833        let findings = vec![sample_finding("f-0001")];
834        let md = render_markdown(
835            &[PathBuf::from("src/lib.rs")],
836            &["login".into()],
837            &findings,
838            10,
839        );
840        assert!(md.contains("# cargo-impact"));
841        assert!(md.contains("## Summary"));
842        // Every finding and every checklist item should be omitted.
843        assert!(md.contains("Budget truncation"));
844    }
845
846    #[test]
847    fn text_renders_all_severity_buckets_even_when_empty() {
848        let text = render_text(
849            &[PathBuf::from("src/lib.rs")],
850            &["login".into()],
851            &[sample_finding("f-0001")],
852        );
853        // Every severity bucket header must appear so the output is consistent
854        // across runs, even when a bucket is empty.
855        assert!(text.contains("HIGH (0)"));
856        assert!(text.contains("MEDIUM (1)"));
857        assert!(text.contains("LOW (0)"));
858        assert!(text.contains("UNKNOWN (0)"));
859    }
860
861    #[test]
862    fn empty_findings_produce_valid_json() {
863        let out = render_json(&[], &[], &[]).unwrap();
864        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
865        assert_eq!(v["summary"]["total"], 0);
866        assert_eq!(v["findings"].as_array().unwrap().len(), 0);
867    }
868
869    // --- SARIF ---
870
871    #[test]
872    fn sarif_emits_stable_envelope_and_schema() {
873        let findings = vec![sample_finding("f-0001")];
874        let out = render_sarif(&findings).unwrap();
875        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
876
877        assert_eq!(v["version"], "2.1.0");
878        assert_eq!(
879            v["$schema"],
880            "https://json.schemastore.org/sarif-2.1.0.json"
881        );
882        let driver = &v["runs"][0]["tool"]["driver"];
883        assert_eq!(driver["name"], "cargo-impact");
884        assert!(driver["rules"].as_array().unwrap().len() >= 12);
885
886        let result = &v["runs"][0]["results"][0];
887        assert_eq!(result["ruleId"], "test_reference");
888        assert_eq!(result["message"]["text"], "direct ref");
889        assert_eq!(
890            result["partialFingerprints"]["primaryLocationLineHash"],
891            "f-0001"
892        );
893    }
894
895    #[test]
896    fn sarif_maps_severity_to_sarif_level() {
897        assert_eq!(sarif_level(SeverityClass::High), "error");
898        assert_eq!(sarif_level(SeverityClass::Medium), "warning");
899        assert_eq!(sarif_level(SeverityClass::Low), "note");
900        assert_eq!(sarif_level(SeverityClass::Unknown), "none");
901    }
902
903    #[test]
904    fn sarif_rule_names_use_camel_case() {
905        assert_eq!(sarif_rule_name("test_reference"), "testReference");
906        assert_eq!(
907            sarif_rule_name("ffi_signature_change"),
908            "ffiSignatureChange"
909        );
910        assert_eq!(sarif_rule_name("trait_impl"), "traitImpl");
911    }
912
913    #[test]
914    fn sarif_rules_cover_every_finding_kind() {
915        // The SARIF rules list must enumerate all FindingKind tags so
916        // scanners can hyperlink every result back to a rule.
917        let rules_tags: std::collections::BTreeSet<String> = FindingKind::all_tags()
918            .iter()
919            .map(|s| (*s).to_string())
920            .collect();
921        assert_eq!(rules_tags.len(), 12);
922        for tag in FindingKind::all_tags() {
923            assert!(
924                !sarif_rule_short(tag).is_empty(),
925                "missing rule description for {tag}"
926            );
927        }
928    }
929
930    #[test]
931    fn sarif_includes_region_only_when_line_available() {
932        // DocDriftLink carries a line number — region should be present.
933        let drift = FindingKind::DocDriftLink {
934            symbol: "Foo".into(),
935            doc: crate::finding::Location {
936                file: PathBuf::from("docs/arch.md"),
937                symbol: "Foo".into(),
938            },
939            line: 42,
940        };
941        let f = Finding::new("f-x", Tier::Likely, 0.9, drift, "intra-doc link");
942        let out = render_sarif(&[f]).unwrap();
943        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
944        assert_eq!(
945            v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]["startLine"],
946            42
947        );
948
949        // TestReference has no line — region should be absent.
950        let test_ref = sample_finding("f-y");
951        let out2 = render_sarif(&[test_ref]).unwrap();
952        let v2: serde_json::Value = serde_json::from_str(&out2).unwrap();
953        assert!(
954            v2["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"].is_null(),
955            "region should be absent when no line is known"
956        );
957    }
958
959    #[test]
960    fn sarif_handles_empty_findings() {
961        let out = render_sarif(&[]).unwrap();
962        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
963        assert_eq!(v["runs"][0]["results"].as_array().unwrap().len(), 0);
964        // Rules list is still populated — schema-defined even when no results.
965        assert!(
966            v["runs"][0]["tool"]["driver"]["rules"]
967                .as_array()
968                .unwrap()
969                .len()
970                >= 12
971        );
972    }
973
974    // --- PR comment ---
975
976    #[test]
977    fn pr_comment_shape_is_stable() {
978        let findings = vec![sample_finding("f-0001")];
979        let out = render_pr_comment(
980            &[PathBuf::from("src/lib.rs")],
981            &["login".into()],
982            &findings,
983            0,
984        );
985        // Header line with severity badges.
986        assert!(out.starts_with("### 🎯 cargo-impact — "));
987        // One <details> block per severity that has findings.
988        assert!(
989            out.contains("<details open><summary>🔴 HIGH")
990                || out.contains("<details><summary>🟡 MEDIUM")
991        );
992        // Context block at the end.
993        assert!(out.contains("📁 1 changed file · 1 candidate symbol"));
994        // Tool attribution sub-footer.
995        assert!(out.contains("cargo-impact v"));
996    }
997
998    #[test]
999    fn pr_comment_expands_high_by_default_collapses_others() {
1000        let mk = |id: &str, sev: SeverityClass| {
1001            let mut f = sample_finding(id);
1002            f.severity = sev;
1003            f
1004        };
1005        let findings = vec![
1006            mk("high-1", SeverityClass::High),
1007            mk("med-1", SeverityClass::Medium),
1008            mk("low-1", SeverityClass::Low),
1009        ];
1010        let out = render_pr_comment(
1011            &[PathBuf::from("src/lib.rs")],
1012            &["login".into()],
1013            &findings,
1014            0,
1015        );
1016        assert!(out.contains("<details open><summary>🔴 HIGH (1)"));
1017        assert!(out.contains("<details><summary>🟡 MEDIUM (1)"));
1018        assert!(out.contains("<details><summary>🔵 LOW (1)"));
1019    }
1020
1021    #[test]
1022    fn pr_comment_empty_findings_still_emits_header() {
1023        let out = render_pr_comment(&[PathBuf::from("src/lib.rs")], &[], &[], 0);
1024        assert!(out.contains("0 findings"));
1025        assert!(out.contains("_No findings — nothing to verify._"));
1026    }
1027
1028    #[test]
1029    fn pr_comment_escapes_pipes_in_evidence() {
1030        let kind = FindingKind::TestReference {
1031            test: crate::finding::Location {
1032                file: PathBuf::from("tests/t.rs"),
1033                symbol: "t".into(),
1034            },
1035            matched_symbols: vec!["f".into()],
1036        };
1037        let f = Finding::new(
1038            "f-pipe",
1039            Tier::Likely,
1040            0.8,
1041            kind,
1042            "evidence with | pipe char",
1043        );
1044        let out = render_pr_comment(&[], &[], &[f], 0);
1045        assert!(out.contains("with \\| pipe"));
1046    }
1047
1048    #[test]
1049    fn pr_comment_budget_truncates_with_notice() {
1050        let findings: Vec<Finding> = (0..30)
1051            .map(|i| sample_finding(&format!("f-{i:02}")))
1052            .collect();
1053        let out = render_pr_comment(
1054            &[PathBuf::from("src/lib.rs")],
1055            &["login".into()],
1056            &findings,
1057            1000,
1058        );
1059        assert!(out.contains("findings omitted"));
1060        assert!(out.contains("--budget=1000"));
1061    }
1062}