1use 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#[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#[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#[derive(Debug, Clone, Serialize)]
49pub struct JsonExternalDependenciesOutput {
50 pub external_dependencies: JsonExternalDependencies,
51}
52
53#[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#[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#[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#[derive(Debug, Clone, Serialize)]
89pub struct JsonIssueTypeContribution {
90 pub issue_type: String,
91 pub count: usize,
92 pub highest_severity: String,
93}
94
95#[derive(Debug, Clone, Serialize)]
97pub struct JsonAnalysisManifest {
98 pub blind_spots: Vec<JsonBlindSpot>,
99 pub notes: Vec<String>,
100}
101
102#[derive(Debug, Clone, Serialize)]
104pub struct JsonBlindSpot {
105 pub area: String,
106 pub description: String,
107}
108
109#[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#[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#[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#[derive(Debug, Clone, Serialize)]
160pub struct JsonGradeChange {
161 pub baseline: String,
162 pub current: String,
163}
164
165pub 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
175pub 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 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}