1use crate::engine::python_list_repr;
10use serde_json::{Map, Value};
11use std::collections::{HashMap, HashSet};
12
13const SPEC_001_REQUIRED_KEYS: &[&str] = &["tool_version", "journal_id", "violations"];
14const PER_VIOLATION_REQUIRED: &[&str] = &["rule_id", "file", "line", "message"];
15
16#[derive(Debug, Clone)]
18pub struct SchemaMismatch(pub String);
19
20impl std::fmt::Display for SchemaMismatch {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 write!(f, "{}", self.0)
23 }
24}
25
26fn json_type_name(v: &Value) -> &'static str {
27 match v {
28 Value::Null => "NoneType",
29 Value::Bool(_) => "bool",
30 Value::Number(n) => {
31 if n.is_f64() {
32 "float"
33 } else {
34 "int"
35 }
36 }
37 Value::String(_) => "str",
38 Value::Array(_) => "list",
39 Value::Object(_) => "dict",
40 }
41}
42
43pub fn validate_payload(payload: &Value, source: &str) -> Result<Vec<Value>, SchemaMismatch> {
48 let Value::Object(obj) = payload else {
49 return Err(SchemaMismatch(format!(
50 "{source}: top-level value is not a JSON object"
51 )));
52 };
53
54 let mut missing: Vec<&str> = SPEC_001_REQUIRED_KEYS
55 .iter()
56 .filter(|k| !obj.contains_key(**k))
57 .copied()
58 .collect();
59 if !missing.is_empty() {
60 missing.sort_unstable();
61 return Err(SchemaMismatch(format!(
62 "{source}: missing required key(s) {} โ not a spec-001 jss-lint JSON report",
63 python_list_repr(&missing)
64 )));
65 }
66
67 let violations_value = obj.get("violations").expect("checked above");
68 let Value::Array(items) = violations_value else {
69 return Err(SchemaMismatch(format!(
70 "{source}: 'violations' must be a list (got {})",
71 json_type_name(violations_value)
72 )));
73 };
74
75 for (i, v) in items.iter().enumerate() {
76 let Value::Object(vobj) = v else {
77 return Err(SchemaMismatch(format!(
78 "{source}: violations[{i}] is not an object"
79 )));
80 };
81 let mut v_missing: Vec<&str> = PER_VIOLATION_REQUIRED
82 .iter()
83 .filter(|k| !vobj.contains_key(**k))
84 .copied()
85 .collect();
86 if !v_missing.is_empty() {
87 v_missing.sort_unstable();
88 return Err(SchemaMismatch(format!(
89 "{source}: violations[{i}] missing key(s) {}",
90 python_list_repr(&v_missing)
91 )));
92 }
93 }
94
95 Ok(items.clone())
96}
97
98#[derive(Debug, Clone)]
100pub struct DiffReport {
101 pub fixed: Vec<Value>,
102 pub introduced: Vec<Value>,
103 pub unchanged: Vec<Value>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash)]
107struct IdentityKey {
108 rule_id: String,
109 file: String,
110 line: Option<i64>,
111 message: String,
112}
113
114fn str_field(obj: &Map<String, Value>, key: &str) -> String {
115 obj.get(key)
116 .and_then(Value::as_str)
117 .unwrap_or_default()
118 .to_string()
119}
120
121fn identity(obj: &Map<String, Value>, drop_line: bool) -> IdentityKey {
122 IdentityKey {
123 rule_id: str_field(obj, "rule_id"),
124 file: str_field(obj, "file"),
125 line: if drop_line {
126 None
127 } else {
128 obj.get("line").and_then(Value::as_i64)
129 },
130 message: str_field(obj, "message"),
131 }
132}
133
134fn apply_renames(violations: &[Value], renames: &HashMap<String, String>) -> Vec<Value> {
135 violations
136 .iter()
137 .map(|v| {
138 let Some(obj) = v.as_object() else {
139 return v.clone();
140 };
141 let rule_id = obj.get("rule_id").and_then(Value::as_str).unwrap_or("");
142 match renames.get(rule_id) {
143 Some(new_id) if new_id != rule_id => {
144 let mut new_obj = obj.clone();
145 new_obj.insert("rule_id".to_string(), Value::String(new_id.clone()));
146 Value::Object(new_obj)
147 }
148 _ => v.clone(),
149 }
150 })
151 .collect()
152}
153
154fn sort_key(v: &Value) -> (String, i64, String) {
155 let Some(obj) = v.as_object() else {
156 return (String::new(), 0, String::new());
157 };
158 (
159 str_field(obj, "file"),
160 obj.get("line").and_then(Value::as_i64).unwrap_or(0),
161 str_field(obj, "rule_id"),
162 )
163}
164
165pub fn compare(
171 old: &[Value],
172 new: &[Value],
173 ignore_line_drift: bool,
174 rule_renames: Option<&HashMap<String, String>>,
175) -> DiffReport {
176 let empty = HashMap::new();
177 let renames = rule_renames.unwrap_or(&empty);
178 let old_norm = apply_renames(old, renames);
179
180 let mut old_index: HashMap<IdentityKey, Value> = HashMap::new();
181 for v in &old_norm {
182 if let Some(obj) = v.as_object() {
183 old_index.insert(identity(obj, ignore_line_drift), v.clone());
184 }
185 }
186 let mut new_index: HashMap<IdentityKey, Value> = HashMap::new();
187 for v in new {
188 if let Some(obj) = v.as_object() {
189 new_index.insert(identity(obj, ignore_line_drift), v.clone());
190 }
191 }
192
193 let old_keys: HashSet<&IdentityKey> = old_index.keys().collect();
194 let new_keys: HashSet<&IdentityKey> = new_index.keys().collect();
195
196 let mut fixed: Vec<Value> = old_keys
197 .difference(&new_keys)
198 .map(|k| old_index[*k].clone())
199 .collect();
200 fixed.sort_by_key(sort_key);
201
202 let mut introduced: Vec<Value> = new_keys
203 .difference(&old_keys)
204 .map(|k| new_index[*k].clone())
205 .collect();
206 introduced.sort_by_key(sort_key);
207
208 let mut unchanged: Vec<Value> = old_keys
210 .intersection(&new_keys)
211 .map(|k| new_index[*k].clone())
212 .collect();
213 unchanged.sort_by_key(sort_key);
214
215 DiffReport {
216 fixed,
217 introduced,
218 unchanged,
219 }
220}
221
222fn v_summary(v: &Value) -> String {
223 let Some(obj) = v.as_object() else {
224 return String::new();
225 };
226 let rule_id = str_field(obj, "rule_id");
227 let file = str_field(obj, "file");
228 let line = obj.get("line").and_then(Value::as_i64).unwrap_or(0);
229 let message = str_field(obj, "message");
230 format!("{rule_id} {file}:{line}: {message}")
231}
232
233pub fn render_terminal(diff: &DiffReport) -> String {
235 let mut lines = vec![format!(
236 "fixed: {} introduced: {} unchanged: {}",
237 diff.fixed.len(),
238 diff.introduced.len(),
239 diff.unchanged.len()
240 )];
241 for (label, group) in [
242 ("Fixed", &diff.fixed),
243 ("Introduced", &diff.introduced),
244 ("Unchanged", &diff.unchanged),
245 ] {
246 if group.is_empty() {
247 continue;
248 }
249 lines.push(String::new());
250 lines.push(format!("== {label} =="));
251 for v in group {
252 lines.push(format!(" {}", v_summary(v)));
253 }
254 }
255 lines.join("\n") + "\n"
256}
257
258pub fn render_markdown(diff: &DiffReport) -> String {
260 let mut parts = vec![
261 format!(
262 "**fixed:** {} | **introduced:** {} | **unchanged:** {}",
263 diff.fixed.len(),
264 diff.introduced.len(),
265 diff.unchanged.len()
266 ),
267 String::new(),
268 ];
269 for (label, group) in [
270 ("Fixed", &diff.fixed),
271 ("Introduced", &diff.introduced),
272 ("Unchanged", &diff.unchanged),
273 ] {
274 parts.push(format!("## {label}"));
275 if group.is_empty() {
276 parts.push(String::new());
277 parts.push("(none)".to_string());
278 parts.push(String::new());
279 continue;
280 }
281 parts.push(String::new());
282 for v in group {
283 let Some(obj) = v.as_object() else { continue };
284 let rule_id = str_field(obj, "rule_id");
285 let file = str_field(obj, "file");
286 let line = obj.get("line").and_then(Value::as_i64).unwrap_or(0);
287 let message = str_field(obj, "message");
288 parts.push(format!("- `{rule_id}` {file}:{line}: {message}"));
289 }
290 parts.push(String::new());
291 }
292 parts.join("\n").trim_end().to_string() + "\n"
293}
294
295pub fn render_json(diff: &DiffReport) -> String {
299 let payload = serde_json::json!({
300 "summary": {
301 "fixed": diff.fixed.len(),
302 "introduced": diff.introduced.len(),
303 "unchanged": diff.unchanged.len(),
304 },
305 "fixed": diff.fixed,
306 "introduced": diff.introduced,
307 "unchanged": diff.unchanged,
308 });
309 let mut out = String::new();
310 crate::json_output::write_value(&payload, 0, &mut out);
311 out.push('\n');
312 out
313}