Skip to main content

fallow_api/
audit_output.rs

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