Skip to main content

deps_cli/format/
json.rs

1//! Versioned JSON output (FR-007).
2//!
3//! Schema mirrors `specs/062-cli-check-mode/plan.md` §4 exactly. `schema_version` lets
4//! downstream tooling detect a future breaking change to this shape (constitution
5//! principle 8) — bump it, and document the bump in `CHANGELOG.md` as `Breaking`, whenever
6//! a field is renamed or removed (adding a new optional field is not itself a bump).
7
8use super::severity_str;
9use crate::report::CheckReport;
10use serde::Serialize;
11use std::collections::BTreeMap;
12
13/// The current `schema_version` this module emits and [`ReportDocument`] can deserialize.
14pub const SCHEMA_VERSION: u32 = 1;
15
16/// Top-level JSON document shape.
17#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
18pub struct ReportDocument {
19    /// The schema version this document was produced under.
20    pub schema_version: u32,
21    /// Every finding, in the order [`CheckReport::findings`] held them.
22    pub findings: Vec<FindingDocument>,
23    /// Per-category finding counts (FR-009's category tokens as keys).
24    pub summary: BTreeMap<String, usize>,
25}
26
27/// One finding's JSON shape.
28#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
29pub struct FindingDocument {
30    /// The ecosystem id (e.g. `"cargo"`).
31    pub ecosystem: String,
32    /// The manifest path, as reported by [`crate::walk`].
33    pub manifest_path: String,
34    /// The dependency name, when the finding could be traced to one manifest occurrence.
35    pub dependency_name: Option<String>,
36    /// The declared version requirement, when known.
37    pub requirement: Option<String>,
38    /// The FR-009 category token.
39    pub category: String,
40    /// The lowercase severity token (`error`/`warning`/`information`/`hint`).
41    pub severity: String,
42    /// The LSP range within the manifest.
43    pub range: RangeDocument,
44    /// The human-readable finding message.
45    pub message: String,
46}
47
48/// LSP `Range`'s JSON shape (`{"start": {...}, "end": {...}}`).
49#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
50pub struct RangeDocument {
51    /// The range's start position.
52    pub start: PositionDocument,
53    /// The range's end position.
54    pub end: PositionDocument,
55}
56
57/// LSP `Position`'s JSON shape (`{"line": ..., "character": ...}`), both zero-based.
58#[derive(Debug, Serialize, serde::Deserialize, PartialEq)]
59pub struct PositionDocument {
60    /// Zero-based line number.
61    pub line: u32,
62    /// Zero-based UTF-16 code-unit offset within the line.
63    pub character: u32,
64}
65
66/// Builds the versioned [`ReportDocument`] for `report`.
67///
68/// # Examples
69///
70/// ```
71/// use deps_cli::format::json::{SCHEMA_VERSION, to_document};
72/// use deps_cli::report::CheckReport;
73///
74/// let document = to_document(&CheckReport::default());
75/// assert_eq!(document.schema_version, SCHEMA_VERSION);
76/// assert!(document.findings.is_empty());
77/// ```
78#[must_use]
79pub fn to_document(report: &CheckReport) -> ReportDocument {
80    let findings = report
81        .findings
82        .iter()
83        .map(|finding| FindingDocument {
84            ecosystem: finding.ecosystem.id().to_string(),
85            manifest_path: finding.manifest_path.display().to_string(),
86            dependency_name: finding.dependency_name.clone(),
87            requirement: finding.requirement.clone(),
88            category: finding.category.as_str().to_string(),
89            severity: severity_str(finding.severity).to_string(),
90            range: RangeDocument {
91                start: PositionDocument {
92                    line: finding.range.start.line,
93                    character: finding.range.start.character,
94                },
95                end: PositionDocument {
96                    line: finding.range.end.line,
97                    character: finding.range.end.character,
98                },
99            },
100            message: finding.message.clone(),
101        })
102        .collect();
103
104    let summary = report
105        .summary()
106        .into_iter()
107        .map(|(category, count)| (category.as_str().to_string(), count))
108        .collect();
109
110    ReportDocument {
111        schema_version: SCHEMA_VERSION,
112        findings,
113        summary,
114    }
115}
116
117/// Renders `report` as a pretty-printed JSON string.
118///
119/// # Errors
120///
121/// Returns an error only if [`ReportDocument`]'s `Serialize` impl fails, which does not
122/// happen for the plain-data shape this module builds.
123pub fn render(report: &CheckReport) -> Result<String, serde_json::Error> {
124    serde_json::to_string_pretty(&to_document(report))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::report::{Category, CheckFinding};
131    use deps_core::EcosystemId;
132    use deps_core::diagnostic::Severity;
133    use deps_core::position::{Position, Range};
134    use std::path::PathBuf;
135
136    fn finding() -> CheckFinding {
137        CheckFinding {
138            ecosystem: EcosystemId::Cargo,
139            manifest_path: PathBuf::from("Cargo.toml"),
140            dependency_name: Some("serde".to_string()),
141            requirement: Some("1.0".to_string()),
142            category: Category::Outdated,
143            code: None,
144            advisory_url: None,
145            advisory_severity: None,
146            severity: Severity::Hint,
147            range: Range::new(Position::new(4, 0), Position::new(4, 10)),
148            message: "Newer version available: 1.1.0".to_string(),
149        }
150    }
151
152    #[test]
153    fn test_to_document_empty_report() {
154        let document = to_document(&CheckReport::default());
155        assert_eq!(document.schema_version, SCHEMA_VERSION);
156        assert!(document.findings.is_empty());
157        assert!(document.summary.is_empty());
158    }
159
160    #[test]
161    fn test_to_document_maps_finding_fields() {
162        let document = to_document(&CheckReport {
163            findings: vec![finding()],
164        });
165        let f = &document.findings[0];
166        assert_eq!(f.ecosystem, "cargo");
167        assert_eq!(f.manifest_path, "Cargo.toml");
168        assert_eq!(f.dependency_name.as_deref(), Some("serde"));
169        assert_eq!(f.requirement.as_deref(), Some("1.0"));
170        assert_eq!(f.category, "outdated");
171        assert_eq!(f.severity, "hint");
172        assert_eq!(f.range.start.line, 4);
173        assert_eq!(document.summary.get("outdated"), Some(&1));
174    }
175
176    #[test]
177    fn test_render_round_trips_through_serde_json() {
178        let report = CheckReport {
179            findings: vec![finding()],
180        };
181        let rendered = render(&report).expect("render must succeed");
182        let parsed: ReportDocument = serde_json::from_str(&rendered).expect("must round-trip");
183        assert_eq!(parsed, to_document(&report));
184    }
185
186    #[test]
187    fn test_render_includes_schema_version_field() {
188        let rendered = render(&CheckReport::default()).expect("render must succeed");
189        assert!(rendered.contains("\"schema_version\": 1"));
190    }
191
192    /// Snapshot test (S5, spec 062 review): pins the exact JSON document shape so a field
193    /// rename, key reordering, or nesting change shows up as a snapshot diff — this
194    /// document is a stable, versioned public schema (`SCHEMA_VERSION`), not an
195    /// implementation detail.
196    #[test]
197    fn test_to_document_snapshot() {
198        let mut other = finding();
199        other.category = Category::Vulnerable;
200        other.severity = Severity::Error;
201        other.manifest_path = PathBuf::from("package.json");
202        other.dependency_name = None;
203        other.requirement = None;
204        other.message = "GHSA-xxxx-yyyy-zzzz: example advisory".to_string();
205
206        let document = to_document(&CheckReport {
207            findings: vec![finding(), other],
208        });
209        insta::assert_json_snapshot!(document);
210    }
211}