Skip to main content

fallow_api/
audit_output.rs

1//! Shared audit JSON payload contracts for programmatic consumers.
2
3use fallow_config::{AuditGate, AuditWorsened};
4use fallow_output::{AuditCommand, CodeClimateIssue, codeclimate_issues_to_value};
5use fallow_types::duplicates::DuplicationReport;
6use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
7use fallow_types::output::NextStep;
8use serde::Serialize;
9
10/// Verdict for the audit command.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(rename_all = "snake_case")]
14pub enum AuditVerdict {
15    /// No issues in changed files.
16    Pass,
17    /// Issues found, but all are warn-severity.
18    Warn,
19    /// Error-severity issues found in changed files.
20    Fail,
21}
22
23/// Per-category summary counts for the audit result.
24#[derive(Debug, Clone, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct AuditSummary {
27    /// Total dead-code issues reported for the changed files.
28    pub dead_code_issues: usize,
29    /// Whether any reported dead-code issue has error severity.
30    pub dead_code_has_errors: bool,
31    /// True when `--only`, `--skip` or `audit.deadCode = false` skipped the
32    /// dead-code sub-analysis. The dead-code counts are then 0 and do not mean
33    /// a clean result. Omitted when the sub-analysis ran.
34    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
35    pub dead_code_skipped: bool,
36    /// True when `--only`, `--skip` or `audit.health = false` skipped the
37    /// health sub-analysis. The complexity and styling counts are then 0 and
38    /// do not mean a clean result. Omitted when the sub-analysis ran.
39    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
40    pub complexity_skipped: bool,
41    /// True when `--only`, `--skip` or `audit.dupes = false` skipped the
42    /// duplication sub-analysis. The clone-group counts are then 0 and do not
43    /// mean a clean result. Omitted when the sub-analysis ran.
44    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
45    pub duplication_skipped: bool,
46    /// Total complexity findings reported for the changed files.
47    pub complexity_findings: usize,
48    /// Highest cyclomatic complexity among the findings; `None` when there
49    /// are no complexity findings.
50    pub max_cyclomatic: Option<u16>,
51    /// Clone groups touching the changed files.
52    pub duplication_clone_groups: usize,
53}
54
55/// New-vs-inherited issue counts for audit.
56#[derive(Debug, Default, Clone, Serialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct AuditAttribution {
59    /// Configured gate: `new-only` fails only on introduced findings,
60    /// `all` also fails on inherited ones.
61    pub gate: AuditGate,
62    /// How a worsened complexity finding affects the verdict under the
63    /// `new-only` gate. `warn` gives at least a `warn` verdict. `error` counts
64    /// the finding like an introduced finding.
65    #[serde(default)]
66    pub worsened: AuditWorsened,
67    /// Dead-code findings absent from the base snapshot.
68    pub dead_code_introduced: usize,
69    /// Dead-code findings already present in the base snapshot.
70    pub dead_code_inherited: usize,
71    /// Complexity findings absent from the base snapshot.
72    pub complexity_introduced: usize,
73    /// Complexity findings already present in the base snapshot.
74    pub complexity_inherited: usize,
75    /// Complexity findings whose `attribution` is `worsened`. These findings
76    /// are in the base snapshot, and a measured value is higher now. They are
77    /// also counted in `complexity_inherited`.
78    #[serde(default)]
79    pub complexity_worsened: usize,
80    /// Clone groups absent from the base snapshot.
81    pub duplication_introduced: usize,
82    /// Clone groups already present in the base snapshot.
83    pub duplication_inherited: usize,
84}
85
86/// Header fields shared by audit JSON and review-brief subtract sections.
87pub struct AuditJsonHeaderInput {
88    /// Output schema version of the envelope.
89    pub schema_version: SchemaVersion,
90    /// Fallow version that produced the output.
91    pub version: ToolVersion,
92    /// Overall audit verdict.
93    pub verdict: AuditVerdict,
94    /// Number of changed files the audit analyzed.
95    pub changed_files_count: u32,
96    /// Git revision the audit compared against.
97    pub base_ref: String,
98    /// Human-readable description of how the base was chosen, such as
99    /// `merge-base with origin/main`; omitted from JSON when `None`.
100    pub base_description: Option<String>,
101    /// Commit SHA of the analyzed head; omitted from JSON when `None`.
102    pub head_sha: Option<String>,
103    /// Audit wall time in milliseconds.
104    pub elapsed_ms: ElapsedMs,
105    /// `Some(true)` when the base snapshot was skipped, so no attribution
106    /// ran; omitted from JSON when `None`.
107    pub base_snapshot_skipped: Option<bool>,
108    /// Per-category issue counts.
109    pub summary: AuditSummary,
110    /// New-vs-inherited counts and the configured gate.
111    pub attribution: AuditAttribution,
112}
113
114/// Typed audit JSON assembly input.
115pub struct AuditJsonOutputInput<DeadCode, Duplication, Complexity> {
116    /// Every gate this run evaluated, absent when it evaluated none. The
117    /// programmatic route runs no CLI-layer gate and leaves this `None`.
118    pub gate_outcomes: Option<fallow_output::GateOutcomes>,
119
120    /// Envelope header fields.
121    pub header: AuditJsonHeaderInput,
122    /// Optional explain metadata block.
123    pub meta: Option<fallow_types::envelope::Meta>,
124    /// Dead-code section payload; `None` omits the section.
125    pub dead_code: Option<DeadCode>,
126    /// Duplication section payload; `None` omits the section.
127    pub duplication: Option<Duplication>,
128    /// Complexity (health) section payload; `None` omits the section.
129    pub complexity: Option<Complexity>,
130    /// Diff-risk findings; an empty report omits the section.
131    pub diff_risk: fallow_output::DiffRiskReport,
132    /// Suggested follow-up commands for the consumer.
133    pub next_steps: Vec<NextStep>,
134}
135
136/// Typed audit SARIF assembly input.
137#[derive(Clone, Copy)]
138pub struct AuditSarifOutputInput<'a> {
139    /// Prebuilt dead-code SARIF document whose runs are merged in.
140    pub dead_code: Option<&'a serde_json::Value>,
141    /// Duplication report converted into a dedicated SARIF run; skipped when
142    /// it has no clone groups.
143    pub duplication: Option<&'a DuplicationReport>,
144    /// Prebuilt health SARIF document whose runs are merged in.
145    pub health: Option<&'a serde_json::Value>,
146    /// Diff-risk findings, converted into a dedicated SARIF run; skipped when
147    /// empty.
148    pub diff_risk: Option<&'a fallow_output::DiffRiskReport>,
149}
150
151/// Typed audit CodeClimate assembly input.
152pub struct AuditCodeClimateOutputInput {
153    /// Dead-code issues, emitted first in the combined array.
154    pub dead_code: Vec<CodeClimateIssue>,
155    /// Duplication issues, emitted after dead code.
156    pub duplication: Vec<CodeClimateIssue>,
157    /// Health issues, emitted after duplication.
158    pub health: Vec<CodeClimateIssue>,
159    /// Diff-risk issues, emitted last.
160    pub diff_risk: Vec<CodeClimateIssue>,
161}
162
163#[derive(Serialize)]
164struct AuditHeaderOutput {
165    schema_version: SchemaVersion,
166    version: ToolVersion,
167    command: AuditCommand,
168    verdict: AuditVerdict,
169    changed_files_count: u32,
170    base_ref: String,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    base_description: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    head_sha: Option<String>,
175    elapsed_ms: ElapsedMs,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    base_snapshot_skipped: Option<bool>,
178    summary: AuditSummary,
179    attribution: AuditAttribution,
180}
181
182fn audit_header_output(input: AuditJsonHeaderInput) -> AuditHeaderOutput {
183    AuditHeaderOutput {
184        schema_version: input.schema_version,
185        version: input.version,
186        command: AuditCommand::Audit,
187        verdict: input.verdict,
188        changed_files_count: input.changed_files_count,
189        base_ref: input.base_ref,
190        base_description: input.base_description,
191        head_sha: input.head_sha,
192        elapsed_ms: input.elapsed_ms,
193        base_snapshot_skipped: input.base_snapshot_skipped,
194        summary: input.summary,
195        attribution: input.attribution,
196    }
197}
198
199/// Build the audit header JSON object used by review brief output.
200///
201/// # Errors
202///
203/// Returns a serde error if one of the typed header fields cannot be converted
204/// to JSON.
205pub fn build_audit_header_json(
206    input: AuditJsonHeaderInput,
207) -> Result<serde_json::Value, serde_json::Error> {
208    serde_json::to_value(audit_header_output(input))
209}
210
211/// Build the audit header as an object map for composed output contracts such
212/// as review briefs.
213///
214/// # Errors
215///
216/// Returns a serde error if one of the typed header fields cannot be converted
217/// to JSON, or if the typed header unexpectedly does not serialize to an
218/// object.
219pub fn build_audit_header_map(
220    input: AuditJsonHeaderInput,
221) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
222    match build_audit_header_json(input)? {
223        serde_json::Value::Object(header) => Ok(header),
224        _ => unreachable!("AuditHeaderOutput serializes to an object"),
225    }
226}
227
228/// Build the typed audit metadata carried by a review brief envelope.
229#[must_use]
230pub fn build_review_brief_header(
231    input: AuditJsonHeaderInput,
232) -> fallow_output::ReviewBriefHeader<AuditVerdict, AuditSummary, AuditAttribution> {
233    fallow_output::ReviewBriefHeader {
234        version: input.version,
235        verdict: input.verdict,
236        changed_files_count: input.changed_files_count,
237        base_ref: input.base_ref,
238        base_description: input.base_description,
239        head_sha: input.head_sha,
240        elapsed_ms: input.elapsed_ms,
241        base_snapshot_skipped: input.base_snapshot_skipped,
242        summary: input.summary,
243        attribution: input.attribution,
244    }
245}
246
247/// Serialize a typed audit JSON output envelope.
248///
249/// # Errors
250///
251/// Returns a serde error if the envelope or one of its nested payload sections
252/// cannot be converted to JSON.
253pub fn serialize_audit_json<DeadCode, Duplication, Complexity>(
254    input: AuditJsonOutputInput<DeadCode, Duplication, Complexity>,
255    analysis_run_id: Option<&str>,
256) -> Result<serde_json::Value, serde_json::Error>
257where
258    DeadCode: Serialize,
259    Duplication: Serialize,
260    Complexity: Serialize,
261{
262    let header = audit_header_output(input.header);
263    let complexity = input.complexity.map(serde_json::to_value).transpose()?;
264    let output = fallow_output::AuditOutput {
265        schema_version: header.schema_version,
266        version: header.version,
267        command: header.command,
268        verdict: header.verdict,
269        changed_files_count: header.changed_files_count,
270        base_ref: header.base_ref,
271        base_description: header.base_description,
272        head_sha: header.head_sha,
273        elapsed_ms: header.elapsed_ms,
274        base_snapshot_skipped: header.base_snapshot_skipped,
275        summary: header.summary,
276        attribution: header.attribution,
277        gate_outcomes: input.gate_outcomes,
278        meta: input.meta,
279        dead_code: input.dead_code,
280        duplication: input.duplication,
281        complexity,
282        diff_risk: (!input.diff_risk.is_empty()).then_some(input.diff_risk),
283        next_steps: input.next_steps,
284    };
285    let mut value = fallow_output::serialize_audit_json_output(output, analysis_run_id)?;
286    attach_audit_wire_attribution(&mut value);
287    Ok(value)
288}
289
290/// Attach every wire-only audit attribution derivation in one pass: styling
291/// counts and the duplication demotion counter.
292///
293/// The single entry point for audit-family envelopes (audit JSON and the
294/// review brief), so a future envelope cannot wire one derivation and miss
295/// the other.
296pub fn attach_audit_wire_attribution(value: &mut serde_json::Value) {
297    attach_audit_styling_attribution(value);
298    attach_audit_duplication_demotion_attribution(value);
299    attach_audit_diff_risk_attribution(value);
300}
301
302/// Add `attribution.diff_risk_introduced`, the number of serialized
303/// `diff_risk.findings[]` entries.
304///
305/// Every diff-risk finding is introduced by construction: the rules compare
306/// the base and head text of the change. Always inserted when an
307/// `attribution` object is present: an integer, `0` when the section is
308/// absent. Wire-only, like the styling counts.
309pub fn attach_audit_diff_risk_attribution(value: &mut serde_json::Value) {
310    let introduced = value
311        .get("diff_risk")
312        .and_then(|section| section.get("findings"))
313        .and_then(serde_json::Value::as_array)
314        .map_or(0, Vec::len);
315    if let Some(attribution) = value
316        .get_mut("attribution")
317        .and_then(serde_json::Value::as_object_mut)
318    {
319        attribution.insert(
320            "diff_risk_introduced".to_string(),
321            serde_json::json!(introduced),
322        );
323    }
324}
325
326/// Add `attribution.duplication_demoted`, derived from serialized clone
327/// groups.
328///
329/// Counts `duplication.clone_groups[]` entries carrying a `demotion_reason`
330/// (set by the new-only gate when it demotes an introduced group to
331/// inherited, issue #2220). Always inserted when an `attribution` object is
332/// present: an integer, `0` when the duplication section is absent. Wire-only,
333/// mirroring [`attach_audit_styling_attribution`], so the public Rust
334/// attribution struct stays source-compatible.
335pub fn attach_audit_duplication_demotion_attribution(value: &mut serde_json::Value) {
336    let demoted = value
337        .get("duplication")
338        .and_then(|duplication| duplication.get("clone_groups"))
339        .and_then(serde_json::Value::as_array)
340        .map_or(0, |items| {
341            items
342                .iter()
343                .filter(|item| item.get("demotion_reason").is_some())
344                .count()
345        });
346    if let Some(attribution) = value
347        .get_mut("attribution")
348        .and_then(serde_json::Value::as_object_mut)
349    {
350        attribution.insert(
351            "duplication_demoted".to_string(),
352            serde_json::json!(demoted),
353        );
354    }
355}
356
357/// Add styling attribution totals derived from annotated styling findings.
358///
359/// This keeps the public Rust attribution and finding structs source-compatible
360/// while extending audit-family JSON envelopes with the wire-only fields.
361pub fn attach_audit_styling_attribution(value: &mut serde_json::Value) {
362    let findings = value
363        .get("complexity")
364        .and_then(|complexity| complexity.get("styling_findings"))
365        .and_then(serde_json::Value::as_array);
366    let styling_introduced = findings.map_or(0, |items| {
367        items
368            .iter()
369            .filter(|item| {
370                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(true)
371            })
372            .count()
373    });
374    let styling_inherited = findings.map_or(0, |items| {
375        items
376            .iter()
377            .filter(|item| {
378                item.get("introduced").and_then(serde_json::Value::as_bool) == Some(false)
379            })
380            .count()
381    });
382    if let Some(attribution) = value
383        .get_mut("attribution")
384        .and_then(serde_json::Value::as_object_mut)
385    {
386        attribution.insert(
387            "styling_introduced".to_string(),
388            serde_json::json!(styling_introduced),
389        );
390        attribution.insert(
391            "styling_inherited".to_string(),
392            serde_json::json!(styling_inherited),
393        );
394    }
395}
396
397/// Build the combined SARIF document for `fallow audit`.
398#[must_use]
399pub fn build_audit_sarif(input: AuditSarifOutputInput<'_>) -> serde_json::Value {
400    let mut all_runs = Vec::new();
401
402    if let Some(sarif) = input.dead_code {
403        extend_sarif_runs(&mut all_runs, sarif);
404    }
405
406    if let Some(duplication) = input.duplication
407        && !duplication.clone_groups.is_empty()
408    {
409        all_runs.push(build_audit_duplication_sarif_run(duplication));
410    }
411
412    if let Some(sarif) = input.health {
413        extend_sarif_runs(&mut all_runs, sarif);
414    }
415
416    if let Some(diff_risk) = input.diff_risk
417        && !diff_risk.is_empty()
418    {
419        all_runs.push(build_audit_diff_risk_sarif_run(diff_risk));
420    }
421
422    serde_json::json!({
423        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
424        "version": "2.1.0",
425        "runs": all_runs,
426    })
427}
428
429fn extend_sarif_runs(all_runs: &mut Vec<serde_json::Value>, sarif: &serde_json::Value) {
430    if let Some(runs) = sarif.get("runs").and_then(|runs| runs.as_array()) {
431        all_runs.extend(runs.iter().cloned());
432    }
433}
434
435fn build_audit_duplication_sarif_run(duplication: &DuplicationReport) -> serde_json::Value {
436    serde_json::json!({
437        "tool": {
438            "driver": {
439                "name": "fallow",
440                "version": env!("CARGO_PKG_VERSION"),
441                "informationUri": "https://github.com/fallow-rs/fallow",
442            }
443        },
444        "automationDetails": { "id": "fallow/audit/dupes" },
445        "results": duplication.clone_groups.iter().enumerate().map(|(i, group)| {
446            serde_json::json!({
447                "ruleId": "fallow/code-duplication",
448                "level": "warning",
449                "message": {
450                    "text": format!(
451                        "Clone group {} ({} lines, {} instances)",
452                        i + 1,
453                        group.line_count,
454                        group.instances.len()
455                    ),
456                },
457            })
458        }).collect::<Vec<_>>()
459    })
460}
461
462fn build_audit_diff_risk_sarif_run(report: &fallow_output::DiffRiskReport) -> serde_json::Value {
463    let mut rules: Vec<fallow_output::DiffRiskRule> =
464        report.findings.iter().map(|finding| finding.rule).collect();
465    rules.sort();
466    rules.dedup();
467    let rule_defs: Vec<serde_json::Value> = rules
468        .iter()
469        .map(|rule| {
470            serde_json::json!({
471                "id": rule.sarif_rule_id(),
472                "shortDescription": { "text": rule.description() },
473                "helpUri": fallow_output::rule_docs_url("cli/audit#diff-risk-rules"),
474            })
475        })
476        .collect();
477    let results: Vec<serde_json::Value> = report
478        .findings
479        .iter()
480        .map(|finding| {
481            let level = match finding.severity {
482                fallow_output::DiffRiskSeverity::Error => "error",
483                fallow_output::DiffRiskSeverity::Warn => "warning",
484            };
485            let mut physical = serde_json::json!({
486                "artifactLocation": { "uri": finding.path },
487            });
488            if let Some(line) = finding.line {
489                physical["region"] = serde_json::json!({ "startLine": line });
490            }
491            let mut result = serde_json::json!({
492                "ruleId": finding.rule.sarif_rule_id(),
493                "level": level,
494                "message": { "text": format!("{} ({})", finding.message, finding.evidence) },
495                "locations": [{ "physicalLocation": physical }],
496            });
497            if !finding.related.is_empty() {
498                result["relatedLocations"] = finding
499                    .related
500                    .iter()
501                    .map(|location| {
502                        serde_json::json!({
503                            "physicalLocation": {
504                                "artifactLocation": { "uri": location.path },
505                                "region": { "startLine": location.line },
506                            }
507                        })
508                    })
509                    .collect();
510            }
511            result
512        })
513        .collect();
514    serde_json::json!({
515        "tool": {
516            "driver": {
517                "name": "fallow",
518                "version": env!("CARGO_PKG_VERSION"),
519                "informationUri": "https://github.com/fallow-rs/fallow",
520                "rules": rule_defs,
521            }
522        },
523        "automationDetails": { "id": "fallow/audit/diff-risk" },
524        "results": results,
525    })
526}
527
528/// Build combined CodeClimate issues for `fallow audit`.
529#[must_use]
530pub fn build_audit_codeclimate_issues(input: AuditCodeClimateOutputInput) -> Vec<CodeClimateIssue> {
531    let mut all_issues = input.dead_code;
532    all_issues.extend(input.duplication);
533    all_issues.extend(input.health);
534    all_issues.extend(input.diff_risk);
535    all_issues
536}
537
538/// Build CodeClimate issues for the diff-risk findings of an audit. Finding
539/// paths are already root-relative.
540#[must_use]
541pub fn build_diff_risk_codeclimate(
542    report: &fallow_output::DiffRiskReport,
543) -> Vec<CodeClimateIssue> {
544    report
545        .findings
546        .iter()
547        .map(|finding| {
548            let check_name = finding.rule.sarif_rule_id();
549            let line = finding.line.unwrap_or(1);
550            let fingerprint = fallow_output::codeclimate_fingerprint_hash(&[
551                &check_name,
552                &finding.path,
553                &line.to_string(),
554                &finding.evidence,
555            ]);
556            fallow_output::build_codeclimate_issue(fallow_output::CodeClimateIssueInput {
557                check_name: &check_name,
558                description: &format!("{} ({})", finding.message, finding.evidence),
559                severity: match finding.severity {
560                    fallow_output::DiffRiskSeverity::Error => {
561                        fallow_output::CodeClimateSeverity::Major
562                    }
563                    fallow_output::DiffRiskSeverity::Warn => {
564                        fallow_output::CodeClimateSeverity::Minor
565                    }
566                },
567                category: "Bug Risk",
568                path: &finding.path,
569                begin_line: finding.line,
570                fingerprint: &fingerprint,
571            })
572        })
573        .collect()
574}
575
576/// Build the combined CodeClimate JSON array for `fallow audit`.
577#[must_use]
578pub fn build_audit_codeclimate(input: AuditCodeClimateOutputInput) -> serde_json::Value {
579    codeclimate_issues_to_value(&build_audit_codeclimate_issues(input))
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn audit_verdict_uses_snake_case_wire_names() {
588        let value = serde_json::to_value(AuditVerdict::Pass).expect("serialize verdict");
589        assert_eq!(value, serde_json::json!("pass"));
590    }
591
592    fn header_input() -> AuditJsonHeaderInput {
593        AuditJsonHeaderInput {
594            schema_version: SchemaVersion(7),
595            version: ToolVersion("0.0.0-test".to_string()),
596            verdict: AuditVerdict::Pass,
597            changed_files_count: 5,
598            base_ref: "abc123".to_string(),
599            base_description: Some("merge-base with origin/main".to_string()),
600            head_sha: Some("def456".to_string()),
601            elapsed_ms: ElapsedMs(12),
602            base_snapshot_skipped: Some(true),
603            summary: AuditSummary {
604                dead_code_issues: 0,
605                dead_code_has_errors: false,
606                dead_code_skipped: false,
607                complexity_skipped: false,
608                duplication_skipped: false,
609                complexity_findings: 0,
610                max_cyclomatic: None,
611                duplication_clone_groups: 0,
612            },
613            attribution: AuditAttribution {
614                gate: AuditGate::NewOnly,
615                ..AuditAttribution::default()
616            },
617        }
618    }
619
620    #[test]
621    fn audit_header_json_uses_typed_contract_fields() {
622        let value = build_audit_header_json(header_input()).expect("serialize audit header");
623
624        assert_eq!(value["schema_version"], 7);
625        assert_eq!(value["command"], "audit");
626        assert_eq!(value["base_description"], "merge-base with origin/main");
627        assert_eq!(value["head_sha"], "def456");
628        assert_eq!(value["base_snapshot_skipped"], true);
629    }
630
631    #[test]
632    fn audit_header_map_uses_typed_contract_fields() {
633        let header = build_audit_header_map(header_input()).expect("serialize audit header");
634
635        assert_eq!(header["schema_version"], 7);
636        assert_eq!(header["command"], "audit");
637        assert_eq!(header["base_description"], "merge-base with origin/main");
638    }
639
640    #[test]
641    fn audit_json_serializer_applies_root_kind_and_sections() {
642        let value = serialize_audit_json(
643            AuditJsonOutputInput {
644                gate_outcomes: None,
645                header: header_input(),
646                meta: None,
647                dead_code: Some(serde_json::json!({"total_issues": 0})),
648                duplication: None::<serde_json::Value>,
649                complexity: None::<serde_json::Value>,
650                diff_risk: fallow_output::DiffRiskReport::default(),
651                next_steps: Vec::new(),
652            },
653            Some("run-1"),
654        )
655        .expect("serialize audit output");
656
657        assert_eq!(value["kind"], "audit");
658        assert_eq!(value["dead_code"]["total_issues"], 0);
659        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
660    }
661
662    #[test]
663    fn audit_sarif_combines_runs_and_duplication_run() {
664        let duplication = DuplicationReport {
665            clone_groups: vec![fallow_types::duplicates::CloneGroup {
666                instances: vec![
667                    fallow_types::duplicates::CloneInstance {
668                        is_symlink: false,
669                        file: "src/a.ts".into(),
670                        start_line: 1,
671                        end_line: 12,
672                        start_col: 1,
673                        end_col: 1,
674                        fragment: "duplicated();".to_string(),
675                    },
676                    fallow_types::duplicates::CloneInstance {
677                        is_symlink: false,
678                        file: "src/b.ts".into(),
679                        start_line: 1,
680                        end_line: 12,
681                        start_col: 1,
682                        end_col: 1,
683                        fragment: "duplicated();".to_string(),
684                    },
685                ],
686                token_count: 40,
687                line_count: 12,
688                similarity: None,
689            }],
690            ..DuplicationReport::default()
691        };
692        let dead_code = serde_json::json!({"runs": [{"automationDetails": {"id": "check"}}]});
693        let health = serde_json::json!({"runs": [{"automationDetails": {"id": "health"}}]});
694
695        let value = build_audit_sarif(AuditSarifOutputInput {
696            dead_code: Some(&dead_code),
697            duplication: Some(&duplication),
698            health: Some(&health),
699            diff_risk: None,
700        });
701
702        assert_eq!(value["version"], "2.1.0");
703        assert_eq!(value["runs"].as_array().expect("runs").len(), 3);
704        assert_eq!(
705            value["runs"][1]["automationDetails"]["id"],
706            "fallow/audit/dupes"
707        );
708    }
709
710    #[test]
711    fn audit_codeclimate_combines_issue_sections() {
712        let issue = CodeClimateIssue {
713            kind: fallow_output::CodeClimateIssueKind::Issue,
714            check_name: "fallow/test".to_string(),
715            description: "test".to_string(),
716            severity: fallow_output::CodeClimateSeverity::Minor,
717            fingerprint: "abc".to_string(),
718            location: fallow_output::CodeClimateLocation {
719                path: "src/a.ts".to_string(),
720                lines: fallow_output::CodeClimateLines {
721                    begin: 1,
722                    end: None,
723                },
724            },
725            other_locations: Vec::new(),
726            categories: vec!["Bug Risk".to_string()],
727            owner: None,
728            group: None,
729            legacy_fingerprint: None,
730        };
731
732        let value = build_audit_codeclimate(AuditCodeClimateOutputInput {
733            dead_code: vec![issue.clone()],
734            duplication: vec![issue.clone()],
735            health: vec![issue.clone()],
736            diff_risk: vec![issue],
737        });
738
739        assert_eq!(value.as_array().expect("issues").len(), 4);
740    }
741}