Skip to main content

cqs/impact/
format.rs

1//! JSON and Mermaid serialization for impact results
2
3use std::path::Path;
4
5use super::types::{DiffImpactResult, ImpactResult};
6
7/// Serialize impact result to JSON, relativizing paths against the project root
8pub fn impact_to_json(result: &ImpactResult, root: &Path) -> serde_json::Value {
9    let callers_json: Vec<_> = result
10        .callers
11        .iter()
12        .map(|c| {
13            let rel = crate::rel_display(&c.file, root);
14            serde_json::json!({
15                "name": c.name,
16                "file": rel,
17                "line": c.line,
18                "call_line": c.call_line,
19                "snippet": c.snippet,
20            })
21        })
22        .collect();
23
24    let tests_json: Vec<_> = result.tests.iter().map(|t| t.to_json(root)).collect();
25
26    let mut output = serde_json::json!({
27        "function": result.function_name,
28        "callers": callers_json,
29        "tests": tests_json,
30        "caller_count": callers_json.len(),
31        "test_count": tests_json.len(),
32    });
33
34    if !result.transitive_callers.is_empty() {
35        let trans_json: Vec<_> = result
36            .transitive_callers
37            .iter()
38            .map(|c| {
39                let rel = crate::rel_display(&c.file, root);
40                serde_json::json!({
41                    "name": c.name,
42                    "file": rel,
43                    "line": c.line,
44                    "depth": c.depth,
45                })
46            })
47            .collect();
48        if let Some(obj) = output.as_object_mut() {
49            obj.insert("transitive_callers".into(), serde_json::json!(trans_json));
50        }
51    }
52
53    if result.degraded {
54        if let Some(obj) = output.as_object_mut() {
55            obj.insert("degraded".into(), serde_json::json!(true));
56        }
57    }
58
59    // Always include type_impacted for consistent JSON structure
60    let type_json: Vec<_> = result
61        .type_impacted
62        .iter()
63        .map(|ti| {
64            let rel = crate::rel_display(&ti.file, root);
65            serde_json::json!({
66                "name": ti.name,
67                "file": rel,
68                "line": ti.line,
69                "shared_types": ti.shared_types,
70            })
71        })
72        .collect();
73    if let Some(obj) = output.as_object_mut() {
74        obj.insert("type_impacted".into(), serde_json::json!(type_json));
75        obj.insert(
76            "type_impacted_count".into(),
77            serde_json::json!(type_json.len()),
78        );
79    }
80
81    output
82}
83
84/// Generate a mermaid diagram from impact result
85pub fn impact_to_mermaid(result: &ImpactResult, root: &Path) -> String {
86    let mut lines = vec!["graph TD".to_string()];
87    lines.push(format!(
88        "    A[\"{}\"]\n    style A fill:#f96",
89        mermaid_escape(&result.function_name)
90    ));
91
92    let mut idx = 1;
93    for c in &result.callers {
94        let rel = crate::rel_display(&c.file, root);
95        let letter = node_letter(idx);
96        lines.push(format!(
97            "    {}[\"{} ({}:{})\"]",
98            letter,
99            mermaid_escape(&c.name),
100            mermaid_escape(&rel),
101            c.line
102        ));
103        lines.push(format!("    {} --> A", letter));
104        idx += 1;
105    }
106
107    for t in &result.tests {
108        let rel = crate::rel_display(&t.file, root);
109        let letter = node_letter(idx);
110        lines.push(format!(
111            "    {}{{\"{}\\n{}\\ndepth: {}\"}}",
112            letter,
113            mermaid_escape(&t.name),
114            mermaid_escape(&rel),
115            t.call_depth
116        ));
117        lines.push(format!("    {} -.-> A", letter));
118        idx += 1;
119    }
120
121    for ti in &result.type_impacted {
122        let rel = crate::rel_display(&ti.file, root);
123        let letter = node_letter(idx);
124        let types_str = ti.shared_types.join(", ");
125        lines.push(format!(
126            "    {}[/\"{} ({}:{})\\nvia: {}\"/]",
127            letter,
128            mermaid_escape(&ti.name),
129            mermaid_escape(&rel),
130            ti.line,
131            mermaid_escape(&types_str),
132        ));
133        lines.push(format!("    {} -. type .-> A", letter));
134        lines.push(format!("    style {} fill:#9cf", letter));
135        idx += 1;
136    }
137
138    lines.join("\n")
139}
140
141/// Serialize diff impact result to JSON
142pub fn diff_impact_to_json(result: &DiffImpactResult, root: &Path) -> serde_json::Value {
143    let changed_json: Vec<_> = result
144        .changed_functions
145        .iter()
146        .map(|f| {
147            serde_json::json!({
148                "name": f.name,
149                "file": f.file.display().to_string(),
150                "line_start": f.line_start,
151            })
152        })
153        .collect();
154
155    let callers_json: Vec<_> = result
156        .all_callers
157        .iter()
158        .map(|c| {
159            let rel = crate::rel_display(&c.file, root);
160            serde_json::json!({
161                "name": c.name,
162                "file": rel,
163                "line": c.line,
164                "call_line": c.call_line,
165            })
166        })
167        .collect();
168
169    let tests_json: Vec<_> = result
170        .all_tests
171        .iter()
172        .map(|t| {
173            let rel = crate::rel_display(&t.file, root);
174            serde_json::json!({
175                "name": t.name,
176                "file": rel,
177                "line": t.line,
178                "via": t.via,
179                "call_depth": t.call_depth,
180            })
181        })
182        .collect();
183
184    serde_json::json!({
185        "changed_functions": changed_json,
186        "callers": callers_json,
187        "tests": tests_json,
188        "summary": {
189            "changed_count": result.summary.changed_count,
190            "caller_count": result.summary.caller_count,
191            "test_count": result.summary.test_count,
192        }
193    })
194}
195
196/// Convert index to spreadsheet-style column label: A..Z, AA..AZ, BA..BZ, ...
197///
198/// Unlike the previous `A1`, `B1` scheme, this produces valid mermaid node IDs
199/// that are unambiguous for any number of nodes.
200fn node_letter(mut i: usize) -> String {
201    let mut result = String::new();
202    loop {
203        result.insert(0, (b'A' + (i % 26) as u8) as char);
204        if i < 26 {
205            break;
206        }
207        i = i / 26 - 1;
208    }
209    result
210}
211
212fn mermaid_escape(s: &str) -> String {
213    s.replace('"', "&quot;")
214        .replace('<', "&lt;")
215        .replace('>', "&gt;")
216}
217
218#[cfg(test)]
219mod tests {
220    use super::super::types::*;
221    use super::*;
222    use std::path::PathBuf;
223
224    // ===== node_letter tests =====
225
226    #[test]
227    fn test_node_letter_single_char() {
228        assert_eq!(node_letter(0), "A");
229        assert_eq!(node_letter(1), "B");
230        assert_eq!(node_letter(25), "Z");
231    }
232
233    #[test]
234    fn test_node_letter_double_char() {
235        assert_eq!(node_letter(26), "AA");
236        assert_eq!(node_letter(27), "AB");
237        assert_eq!(node_letter(51), "AZ");
238        assert_eq!(node_letter(52), "BA");
239    }
240
241    #[test]
242    fn test_node_letter_triple_char() {
243        assert_eq!(node_letter(702), "AAA");
244    }
245
246    // ===== mermaid_escape tests =====
247
248    #[test]
249    fn test_mermaid_escape_quotes() {
250        assert_eq!(mermaid_escape("hello \"world\""), "hello &quot;world&quot;");
251    }
252
253    #[test]
254    fn test_mermaid_escape_angle_brackets() {
255        assert_eq!(mermaid_escape("Vec<T>"), "Vec&lt;T&gt;");
256    }
257
258    #[test]
259    fn test_mermaid_escape_no_special() {
260        assert_eq!(mermaid_escape("plain_text"), "plain_text");
261    }
262
263    #[test]
264    fn test_mermaid_escape_all_special() {
265        assert_eq!(mermaid_escape("\"<>\""), "&quot;&lt;&gt;&quot;");
266    }
267
268    // ===== impact_to_json tests =====
269
270    #[test]
271    fn test_impact_to_json_structure() {
272        let result = ImpactResult {
273            function_name: "target_fn".to_string(),
274            callers: vec![CallerDetail {
275                name: "caller_a".to_string(),
276                file: PathBuf::from("/project/src/lib.rs"),
277                line: 10,
278                call_line: 15,
279                snippet: Some("target_fn()".to_string()),
280            }],
281            tests: vec![TestInfo {
282                name: "test_target".to_string(),
283                file: PathBuf::from("/project/tests/test.rs"),
284                line: 1,
285                call_depth: 2,
286            }],
287            transitive_callers: Vec::new(),
288            type_impacted: Vec::new(),
289            degraded: false,
290        };
291        let root = Path::new("/project");
292        let json = impact_to_json(&result, root);
293
294        assert_eq!(json["function"], "target_fn");
295        assert_eq!(json["caller_count"], 1);
296        assert_eq!(json["test_count"], 1);
297
298        let callers = json["callers"].as_array().unwrap();
299        assert_eq!(callers[0]["name"], "caller_a");
300        assert_eq!(callers[0]["file"], "src/lib.rs");
301        assert_eq!(callers[0]["line"], 10);
302        assert_eq!(callers[0]["call_line"], 15);
303        assert_eq!(callers[0]["snippet"], "target_fn()");
304
305        let tests = json["tests"].as_array().unwrap();
306        assert_eq!(tests[0]["name"], "test_target");
307        assert_eq!(tests[0]["call_depth"], 2);
308    }
309
310    #[test]
311    fn test_impact_to_json_with_transitive() {
312        let result = ImpactResult {
313            function_name: "target".to_string(),
314            callers: Vec::new(),
315            tests: Vec::new(),
316            transitive_callers: vec![TransitiveCaller {
317                name: "indirect".to_string(),
318                file: PathBuf::from("/project/src/app.rs"),
319                line: 5,
320                depth: 2,
321            }],
322            type_impacted: Vec::new(),
323            degraded: false,
324        };
325        let root = Path::new("/project");
326        let json = impact_to_json(&result, root);
327
328        assert!(json["transitive_callers"].is_array());
329        let trans = json["transitive_callers"].as_array().unwrap();
330        assert_eq!(trans.len(), 1);
331        assert_eq!(trans[0]["name"], "indirect");
332        assert_eq!(trans[0]["depth"], 2);
333    }
334
335    #[test]
336    fn test_impact_to_json_empty() {
337        let result = ImpactResult {
338            function_name: "lonely".to_string(),
339            callers: Vec::new(),
340            tests: Vec::new(),
341            transitive_callers: Vec::new(),
342            type_impacted: Vec::new(),
343            degraded: false,
344        };
345        let root = Path::new("/project");
346        let json = impact_to_json(&result, root);
347
348        assert_eq!(json["function"], "lonely");
349        assert_eq!(json["caller_count"], 0);
350        assert_eq!(json["test_count"], 0);
351        assert!(json.get("transitive_callers").is_none());
352        assert_eq!(json["type_impacted"].as_array().unwrap().len(), 0);
353        assert_eq!(json["type_impacted_count"], 0);
354    }
355
356    // ===== diff_impact_to_json tests =====
357
358    #[test]
359    fn test_diff_impact_to_json_structure() {
360        let result = DiffImpactResult {
361            changed_functions: vec![ChangedFunction {
362                name: "changed_fn".to_string(),
363                file: PathBuf::from("src/lib.rs"),
364                line_start: 10,
365            }],
366            all_callers: vec![CallerDetail {
367                name: "caller_a".to_string(),
368                file: PathBuf::from("/project/src/app.rs"),
369                line: 20,
370                call_line: 25,
371                snippet: None,
372            }],
373            all_tests: vec![DiffTestInfo {
374                name: "test_changed".to_string(),
375                file: PathBuf::from("/project/tests/test.rs"),
376                line: 1,
377                via: "changed_fn".to_string(),
378                call_depth: 1,
379            }],
380            summary: DiffImpactSummary {
381                changed_count: 1,
382                caller_count: 1,
383                test_count: 1,
384            },
385        };
386        let root = Path::new("/project");
387        let json = diff_impact_to_json(&result, root);
388
389        let changed = json["changed_functions"].as_array().unwrap();
390        assert_eq!(changed.len(), 1);
391        assert_eq!(changed[0]["name"], "changed_fn");
392
393        let callers = json["callers"].as_array().unwrap();
394        assert_eq!(callers.len(), 1);
395        assert_eq!(callers[0]["name"], "caller_a");
396
397        let tests = json["tests"].as_array().unwrap();
398        assert_eq!(tests.len(), 1);
399        assert_eq!(tests[0]["name"], "test_changed");
400        assert_eq!(tests[0]["via"], "changed_fn");
401        assert_eq!(tests[0]["call_depth"], 1);
402
403        assert_eq!(json["summary"]["changed_count"], 1);
404        assert_eq!(json["summary"]["caller_count"], 1);
405        assert_eq!(json["summary"]["test_count"], 1);
406    }
407
408    #[test]
409    fn test_diff_impact_to_json_empty() {
410        let result = DiffImpactResult {
411            changed_functions: Vec::new(),
412            all_callers: Vec::new(),
413            all_tests: Vec::new(),
414            summary: DiffImpactSummary {
415                changed_count: 0,
416                caller_count: 0,
417                test_count: 0,
418            },
419        };
420        let root = Path::new("/project");
421        let json = diff_impact_to_json(&result, root);
422
423        assert_eq!(json["changed_functions"].as_array().unwrap().len(), 0);
424        assert_eq!(json["callers"].as_array().unwrap().len(), 0);
425        assert_eq!(json["tests"].as_array().unwrap().len(), 0);
426        assert_eq!(json["summary"]["changed_count"], 0);
427    }
428}