Skip to main content

cargo_coupling/cli_output/
json.rs

1//! Structured reports and their evidence metadata.
2
3use super::hotspots::{Hotspot, calculate_hotspots_with_cycles};
4use crate::balance::subdomain::{build_target_subdomain_map, coupling_with_essential_volatility};
5use crate::balance::{project::analyze_project_balance_with_thresholds, score::BalanceScore};
6use crate::diff::BaselineDiff;
7use crate::external::{
8    ExternalDependencyReport, ExternalDependencyUsage, analyze_external_dependencies,
9};
10use crate::manifest::AnalysisManifest;
11use crate::{CouplingIssue, Distance, IssueThresholds, ProjectMetrics, Severity};
12use serde::Serialize;
13use std::{
14    collections::{HashMap, HashSet},
15    io::{self, Write},
16};
17
18/// Temporal coupling in JSON format
19#[derive(Debug, Clone, Serialize)]
20pub struct JsonTemporalCoupling {
21    pub file_a: String,
22    pub file_b: String,
23    pub co_change_count: usize,
24    pub coupling_ratio: f64,
25    pub is_strong: bool,
26}
27
28/// Complete analysis in JSON format
29#[derive(Debug, Clone, Serialize)]
30pub struct JsonOutput {
31    pub schema_version: u32,
32    pub analyzer_version: String,
33    pub scoring_version: String,
34    pub summary: JsonSummary,
35    pub grade_rationale: JsonGradeRationale,
36    pub analysis_manifest: JsonAnalysisManifest,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub diff: Option<JsonBaselineDiff>,
39    pub external_dependencies: JsonExternalDependencies,
40    pub hotspots: Vec<Hotspot>,
41    pub issues: Vec<JsonIssue>,
42    pub circular_dependencies: Vec<Vec<String>>,
43    pub temporal_couplings: Vec<JsonTemporalCoupling>,
44    pub modules: Vec<JsonModule>,
45}
46
47/// Standalone external-dependency JSON output.
48#[derive(Debug, Clone, Serialize)]
49pub struct JsonExternalDependenciesOutput {
50    pub external_dependencies: JsonExternalDependencies,
51}
52
53/// External dependency analysis in JSON format.
54#[derive(Debug, Clone, Serialize)]
55pub struct JsonExternalDependencies {
56    pub total_crates: usize,
57    pub total_references: usize,
58    pub dependencies: Vec<ExternalDependencyUsage>,
59    pub scattered_couplings: Vec<JsonIssue>,
60}
61
62/// Summary in JSON format
63#[derive(Debug, Clone, Serialize)]
64pub struct JsonSummary {
65    pub health_grade: String,
66    pub health_score: f64,
67    pub total_modules: usize,
68    pub total_couplings: usize,
69    pub internal_couplings: usize,
70    pub external_couplings: usize,
71    pub critical_issues: usize,
72    pub high_issues: usize,
73    pub medium_issues: usize,
74}
75
76/// Health-grade rationale in JSON format.
77#[derive(Debug, Clone, Serialize)]
78pub struct JsonGradeRationale {
79    pub summary: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub dominant_dimension: Option<String>,
82    pub top_issue_types: Vec<JsonIssueTypeContribution>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub note: Option<String>,
85}
86
87/// Issue-type contribution in JSON format.
88#[derive(Debug, Clone, Serialize)]
89pub struct JsonIssueTypeContribution {
90    pub issue_type: String,
91    pub count: usize,
92    pub highest_severity: String,
93}
94
95/// Declared analysis blind spots in JSON format
96#[derive(Debug, Clone, Serialize)]
97pub struct JsonAnalysisManifest {
98    pub blind_spots: Vec<JsonBlindSpot>,
99    pub notes: Vec<String>,
100}
101
102/// Structural blind spot in JSON format
103#[derive(Debug, Clone, Serialize)]
104pub struct JsonBlindSpot {
105    pub area: String,
106    pub description: String,
107}
108
109/// Issue in JSON format
110#[derive(Debug, Clone, Serialize)]
111pub struct JsonIssue {
112    pub id: String,
113    pub issue_type: String,
114    pub severity: String,
115    pub source: String,
116    pub target: String,
117    pub description: String,
118    pub suggestion: String,
119    pub balance_score: f64,
120    pub evidence: Vec<JsonIssueEvidence>,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub struct JsonIssueEvidence {
125    pub file_path: Option<String>,
126    pub line: usize,
127    pub observed_usage: Option<String>,
128    pub inferred_strength: String,
129    pub source: String,
130    pub target: String,
131    pub limitation: String,
132}
133
134/// Module in JSON format
135#[derive(Debug, Clone, Serialize)]
136pub struct JsonModule {
137    pub name: String,
138    pub file_path: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub subdomain: Option<String>,
141    pub couplings_out: usize,
142    pub couplings_in: usize,
143    pub balance_score: f64,
144    pub in_cycle: bool,
145}
146
147/// Baseline diff in JSON format.
148#[derive(Debug, Clone, Serialize)]
149pub struct JsonBaselineDiff {
150    pub new_issues: Vec<JsonIssue>,
151    pub resolved_issues: Vec<JsonIssue>,
152    pub worsened_issues: Vec<JsonIssue>,
153    pub unchanged: usize,
154    pub score_delta: f64,
155    pub grade_change: JsonGradeChange,
156}
157
158/// Baseline/current grade transition in JSON format.
159#[derive(Debug, Clone, Serialize)]
160pub struct JsonGradeChange {
161    pub baseline: String,
162    pub current: String,
163}
164
165/// Generate complete JSON output
166pub fn generate_json_output<W: Write>(
167    metrics: &ProjectMetrics,
168    thresholds: &IssueThresholds,
169    manifest: &AnalysisManifest,
170    writer: &mut W,
171) -> io::Result<()> {
172    generate_json_output_with_optional_diff(metrics, thresholds, manifest, None, writer)
173}
174
175/// Generate complete JSON output with a top-level baseline diff object.
176pub fn generate_json_output_with_diff<W: Write>(
177    metrics: &ProjectMetrics,
178    thresholds: &IssueThresholds,
179    manifest: &AnalysisManifest,
180    diff: &BaselineDiff,
181    writer: &mut W,
182) -> io::Result<()> {
183    generate_json_output_with_optional_diff(metrics, thresholds, manifest, Some(diff), writer)
184}
185
186fn generate_json_output_with_optional_diff<W: Write>(
187    metrics: &ProjectMetrics,
188    thresholds: &IssueThresholds,
189    manifest: &AnalysisManifest,
190    diff: Option<&BaselineDiff>,
191    writer: &mut W,
192) -> io::Result<()> {
193    let report = analyze_project_balance_with_thresholds(metrics, thresholds);
194    let external_dependencies = analyze_external_dependencies(metrics, &HashMap::new());
195    let circular_deps = metrics.detect_circular_dependencies();
196    let evidence_graph = crate::design::graph::DependencyGraph::from_metrics(metrics);
197    let canonical = |name: &str| {
198        evidence_graph
199            .resolve(name)
200            .unwrap_or_else(|| name.to_string())
201    };
202    let cycle_modules: HashSet<String> = circular_deps
203        .iter()
204        .flatten()
205        .map(|name| canonical(name))
206        .collect();
207    let target_subdomains = build_target_subdomain_map(metrics);
208    let hotspots = calculate_hotspots_with_cycles(metrics, thresholds, 10, &circular_deps);
209
210    // Count couplings per module
211    let mut couplings_out: HashMap<String, usize> = HashMap::new();
212    let mut couplings_in: HashMap<String, usize> = HashMap::new();
213    let mut balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
214    let mut internal_count = 0;
215
216    for coupling in &metrics.couplings {
217        if coupling.distance != Distance::DifferentCrate {
218            internal_count += 1;
219            *couplings_out
220                .entry(canonical(&coupling.source))
221                .or_default() += 1;
222            *couplings_in.entry(canonical(&coupling.target)).or_default() += 1;
223            let score = BalanceScore::calculate(&coupling_with_essential_volatility(
224                coupling,
225                &target_subdomains,
226            ));
227            balance_scores
228                .entry(canonical(&coupling.source))
229                .or_default()
230                .push(score.score);
231        }
232    }
233
234    let external_count = metrics.couplings.len() - internal_count;
235
236    let critical = *report
237        .issues_by_severity
238        .get(&Severity::Critical)
239        .unwrap_or(&0);
240    let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
241    let medium = *report
242        .issues_by_severity
243        .get(&Severity::Medium)
244        .unwrap_or(&0);
245
246    let mut temporal_couplings: Vec<JsonTemporalCoupling> = metrics
247        .temporal_couplings
248        .iter()
249        .map(|tc| JsonTemporalCoupling {
250            file_a: tc.file_a.clone(),
251            file_b: tc.file_b.clone(),
252            co_change_count: tc.co_change_count,
253            coupling_ratio: tc.coupling_ratio,
254            is_strong: tc.is_strong(),
255        })
256        .collect();
257    temporal_couplings.sort_by(|a, b| {
258        b.co_change_count
259            .cmp(&a.co_change_count)
260            .then_with(|| {
261                b.coupling_ratio
262                    .partial_cmp(&a.coupling_ratio)
263                    .unwrap_or(std::cmp::Ordering::Equal)
264            })
265            .then_with(|| a.file_a.cmp(&b.file_a))
266            .then_with(|| a.file_b.cmp(&b.file_b))
267    });
268    temporal_couplings.truncate(20);
269
270    let mut modules: Vec<JsonModule> = metrics
271        .modules
272        .iter()
273        .map(|(name, module)| {
274            let avg_score = balance_scores
275                .get(name)
276                .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
277                .unwrap_or(1.0);
278            JsonModule {
279                name: name.clone(),
280                file_path: Some(module.path.display().to_string()),
281                subdomain: module.subdomain.map(|subdomain| subdomain.to_string()),
282                couplings_out: couplings_out.get(name).copied().unwrap_or(0),
283                couplings_in: couplings_in.get(name).copied().unwrap_or(0),
284                balance_score: avg_score,
285                in_cycle: cycle_modules.contains(name),
286            }
287        })
288        .collect();
289    modules.sort_by(|a, b| a.name.cmp(&b.name));
290
291    let output = JsonOutput {
292        schema_version: 2,
293        analyzer_version: env!("CARGO_PKG_VERSION").into(),
294        scoring_version: crate::balance::score::SCORING_VERSION.into(),
295        summary: JsonSummary {
296            health_grade: report.health_grade.letter().to_string(),
297            health_score: report.average_score,
298            total_modules: metrics.modules.len(),
299            total_couplings: metrics.couplings.len(),
300            internal_couplings: internal_count,
301            external_couplings: external_count,
302            critical_issues: critical,
303            high_issues: high,
304            medium_issues: medium,
305        },
306        grade_rationale: JsonGradeRationale {
307            summary: report.grade_rationale.summary.clone(),
308            dominant_dimension: report
309                .grade_rationale
310                .dominant_dimension
311                .map(|dimension| dimension.to_string()),
312            top_issue_types: report
313                .grade_rationale
314                .top_issue_types
315                .iter()
316                .map(|item| JsonIssueTypeContribution {
317                    issue_type: item.issue_type.to_string(),
318                    count: item.count,
319                    highest_severity: item.highest_severity.to_string(),
320                })
321                .collect(),
322            note: report.grade_rationale.volatility_note.clone(),
323        },
324        analysis_manifest: JsonAnalysisManifest {
325            blind_spots: manifest
326                .blind_spots
327                .iter()
328                .map(|blind_spot| JsonBlindSpot {
329                    area: blind_spot.area.to_string(),
330                    description: if thresholds.japanese {
331                        blind_spot.description_ja.to_string()
332                    } else {
333                        blind_spot.description.to_string()
334                    },
335                })
336                .collect(),
337            notes: manifest.localized_notes(thresholds.japanese).to_vec(),
338        },
339        diff: diff.map(json_baseline_diff),
340        external_dependencies: json_external_dependencies(&external_dependencies),
341        hotspots,
342        issues: report
343            .issues
344            .iter()
345            .map(|issue| json_issue_with_evidence(issue, metrics, &evidence_graph))
346            .collect(),
347        circular_dependencies: circular_deps,
348        temporal_couplings,
349        modules,
350    };
351
352    let json = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
353    writeln!(writer, "{}", json)?;
354
355    Ok(())
356}
357
358fn json_baseline_diff(diff: &BaselineDiff) -> JsonBaselineDiff {
359    JsonBaselineDiff {
360        new_issues: diff.new_issues.iter().map(json_issue).collect(),
361        resolved_issues: diff.resolved_issues.iter().map(json_issue).collect(),
362        worsened_issues: diff.worsened_issues.iter().map(json_issue).collect(),
363        unchanged: diff.unchanged,
364        score_delta: diff.score_delta,
365        grade_change: JsonGradeChange {
366            baseline: diff.baseline_grade.letter().to_string(),
367            current: diff.current_grade.letter().to_string(),
368        },
369    }
370}
371
372fn json_issue(issue: &CouplingIssue) -> JsonIssue {
373    let key = issue.stable_key();
374    JsonIssue {
375        id: crate::design::source::fingerprint(
376            format!("{:?}\0{}\0{}", key.issue_type, key.source, key.target).as_bytes(),
377        ),
378        issue_type: format!("{}", issue.issue_type),
379        severity: format!("{}", issue.severity),
380        source: issue.source.clone(),
381        target: issue.target.clone(),
382        description: issue.description.clone(),
383        suggestion: format!("{}", issue.refactoring),
384        balance_score: issue.balance_score,
385        evidence: Vec::new(),
386    }
387}
388
389fn json_issue_with_evidence(
390    issue: &CouplingIssue,
391    metrics: &ProjectMetrics,
392    graph: &crate::design::graph::DependencyGraph,
393) -> JsonIssue {
394    let mut json = json_issue(issue);
395    let source = graph.resolve(&issue.source);
396    let target = graph.resolve(&issue.target);
397    json.evidence = metrics.couplings.iter().filter(|edge| {
398        let source_matches = source.as_ref().is_some_and(|source| graph.resolve(&edge.source).as_ref() == Some(source));
399        let target_matches = target.as_ref().is_some_and(|target| graph.resolve(&edge.target).as_ref() == Some(target));
400        match (source.is_some(),target.is_some()) {
401            (true,true) => source_matches && target_matches,
402            (true,false) => source_matches,
403            (false,true) => target_matches,
404            _ => false,
405        }
406    }).map(|edge| JsonIssueEvidence {
407        file_path: edge.location.file_path.as_ref().map(|path| path.display().to_string()), line: edge.location.line,
408        observed_usage: edge.observed_usage.clone(), inferred_strength: format!("{:?}",edge.strength), source: edge.source.clone(), target: edge.target.clone(),
409        limitation: "Syntax and visibility are observed; shared business knowledge is inferred. Use --design for context, history and unobserved constraints.".into(),
410    }).collect();
411    json.evidence.sort_by(|a, b| {
412        (
413            &a.file_path,
414            a.line,
415            &a.source,
416            &a.target,
417            &a.observed_usage,
418        )
419            .cmp(&(
420                &b.file_path,
421                b.line,
422                &b.source,
423                &b.target,
424                &b.observed_usage,
425            ))
426    });
427    json
428}
429
430pub(super) fn json_external_dependencies(
431    report: &ExternalDependencyReport,
432) -> JsonExternalDependencies {
433    JsonExternalDependencies {
434        total_crates: report.dependencies.len(),
435        total_references: report
436            .dependencies
437            .iter()
438            .map(|dependency| dependency.total_references)
439            .sum(),
440        dependencies: report.dependencies.clone(),
441        scattered_couplings: report.scattered_couplings.iter().map(json_issue).collect(),
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::{CouplingMetrics, IntegrationStrength, ModuleMetrics, Subdomain, Volatility};
449
450    #[test]
451    fn module_counts_resolve_aliases_and_scores_use_essential_volatility() {
452        let mut metrics = ProjectMetrics::new();
453        metrics.add_module(ModuleMetrics::new("consumer.rs".into(), "consumer".into()));
454        let mut provider = ModuleMetrics::new("provider.rs".into(), "provider".into());
455        provider.subdomain = Some(Subdomain::Generic);
456        metrics.add_module(provider);
457        metrics.add_coupling(CouplingMetrics::new(
458            "app::consumer".into(),
459            "app::provider".into(),
460            IntegrationStrength::Intrusive,
461            Distance::DifferentModule,
462            Volatility::High,
463        ));
464        let mut buffer = Vec::new();
465        generate_json_output(
466            &metrics,
467            &IssueThresholds::default(),
468            &AnalysisManifest::default(),
469            &mut buffer,
470        )
471        .unwrap();
472        let json: serde_json::Value = serde_json::from_slice(&buffer).unwrap();
473        let consumer = json["modules"]
474            .as_array()
475            .unwrap()
476            .iter()
477            .find(|row| row["name"] == "consumer")
478            .unwrap();
479        assert_eq!(consumer["couplings_out"], 1);
480        assert_eq!(
481            consumer["balance_score"],
482            1.0 - Subdomain::Generic.expected_volatility().value()
483        );
484    }
485}