Skip to main content

the_code_graph_cli/
output.rs

1use std::io::Write;
2
3use domain::model::{
4    AffectedNode, CloneAnalysis, CloneCluster, Community, CommunityAnalysis, CriticalityScore,
5    DeadCodeAnalysis, DiffImpactReport, EmbedStats, EntryPointKind, FlowAnalysis, GraphStats,
6    ImpactReport, IndexStats, Reference, RiskAnalysis, RiskScore, RiskWeights, SearchResult,
7    SymbolNode,
8};
9
10/// Wraps a RiskScore with contextual info for single-target display (AC3).
11pub struct RiskScoreDetail {
12    pub score: RiskScore,
13    pub matched_patterns: Vec<String>,
14    pub weights: RiskWeights,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct FindResult {
19    pub symbol: SymbolNode,
20    pub callers: Vec<String>,
21    pub callees: Vec<String>,
22    pub tested_by: Vec<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27    Compact,
28    Table,
29    Json,
30}
31
32impl OutputFormat {
33    pub fn from_flags(json: bool, table: bool) -> Self {
34        if json {
35            Self::Json
36        } else if table {
37            Self::Table
38        } else {
39            Self::Compact
40        }
41    }
42}
43
44pub trait Displayable {
45    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()>;
46    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()>;
47    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()>;
48}
49
50pub fn print<T: Displayable>(value: &T, format: OutputFormat) {
51    let stdout = std::io::stdout();
52    let mut w = stdout.lock();
53    match format {
54        OutputFormat::Compact => value.fmt_compact(&mut w),
55        OutputFormat::Table => value.fmt_table(&mut w),
56        OutputFormat::Json => value.fmt_json(&mut w),
57    }
58    .expect("failed to write to stdout");
59}
60
61impl Displayable for IndexStats {
62    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
63        writeln!(
64            w,
65            "Indexed {} files, {} symbols, {} edges in {:.1}s",
66            self.files_indexed,
67            self.symbols_extracted,
68            self.edges_created,
69            self.duration.as_secs_f64()
70        )
71    }
72
73    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
74        writeln!(w, "Metric         | Count")?;
75        writeln!(w, "---------------+----------")?;
76        writeln!(w, "Files indexed  | {}", self.files_indexed)?;
77        writeln!(w, "Symbols        | {}", self.symbols_extracted)?;
78        writeln!(w, "Edges          | {}", self.edges_created)?;
79        writeln!(w, "Duration       | {:.1}s", self.duration.as_secs_f64())
80    }
81
82    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
83        let json = serde_json::to_string_pretty(&self).map_err(std::io::Error::other)?;
84        writeln!(w, "{json}")
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Displayable: Vec<FindResult>
90// ---------------------------------------------------------------------------
91
92impl Displayable for Vec<FindResult> {
93    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
94        for fr in self {
95            let s = &fr.symbol;
96            let loc = &s.location;
97            writeln!(
98                w,
99                "{} {:?} {}:{}-{}",
100                s.name,
101                s.kind,
102                loc.file.display(),
103                loc.line_start,
104                loc.line_end
105            )?;
106            if !fr.callees.is_empty() {
107                writeln!(w, "  -> calls: {}", fr.callees.join(", "))?;
108            }
109            if !fr.tested_by.is_empty() {
110                writeln!(w, "  -> tested_by: {}", fr.tested_by.join(", "))?;
111            }
112            if !fr.callers.is_empty() {
113                writeln!(w, "  <- callers: {}", fr.callers.join(", "))?;
114            }
115        }
116        Ok(())
117    }
118
119    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
120        writeln!(w, "Name | Kind | File | Lines | Callers | Callees")?;
121        writeln!(w, "-----+------+------+-------+---------+--------")?;
122        for fr in self {
123            let s = &fr.symbol;
124            let loc = &s.location;
125            writeln!(
126                w,
127                "{} | {:?} | {} | {}-{} | {} | {}",
128                s.name,
129                s.kind,
130                loc.file.display(),
131                loc.line_start,
132                loc.line_end,
133                fr.callers.join(", "),
134                fr.callees.join(", ")
135            )?;
136        }
137        Ok(())
138    }
139
140    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
141        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
142        writeln!(w, "{json}")
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Displayable: Vec<Reference>
148// ---------------------------------------------------------------------------
149
150impl Displayable for Vec<Reference> {
151    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
152        for r in self {
153            writeln!(w, "{} ({:?})", r.symbol, r.edge_kind)?;
154        }
155        Ok(())
156    }
157
158    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
159        writeln!(w, "Symbol | EdgeKind")?;
160        writeln!(w, "-------+---------")?;
161        for r in self {
162            writeln!(w, "{} | {:?}", r.symbol, r.edge_kind)?;
163        }
164        Ok(())
165    }
166
167    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
168        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
169        writeln!(w, "{json}")
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Displayable: Vec<SearchResult>
175// ---------------------------------------------------------------------------
176
177impl Displayable for Vec<SearchResult> {
178    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
179        for sr in self {
180            writeln!(
181                w,
182                "{} {:?} {} score={:.2}",
183                sr.qualified_name,
184                sr.kind,
185                sr.file_path.display(),
186                sr.score
187            )?;
188        }
189        Ok(())
190    }
191
192    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
193        writeln!(w, "QualifiedName | Kind | File | Score")?;
194        writeln!(w, "--------------+------+------+------")?;
195        for sr in self {
196            writeln!(
197                w,
198                "{} | {:?} | {} | {:.2}",
199                sr.qualified_name,
200                sr.kind,
201                sr.file_path.display(),
202                sr.score
203            )?;
204        }
205        Ok(())
206    }
207
208    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
209        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
210        writeln!(w, "{json}")
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Displayable: EmbedStats
216// ---------------------------------------------------------------------------
217
218impl Displayable for EmbedStats {
219    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
220        writeln!(
221            w,
222            "Embedded {}/{} symbols ({} skipped, {} orphans removed)",
223            self.embedded, self.total_symbols, self.skipped, self.removed
224        )
225    }
226
227    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
228        writeln!(w, "Metric           | Count")?;
229        writeln!(w, "-----------------+------")?;
230        writeln!(w, "Total symbols    | {}", self.total_symbols)?;
231        writeln!(w, "Embedded         | {}", self.embedded)?;
232        writeln!(w, "Skipped          | {}", self.skipped)?;
233        writeln!(w, "Orphans removed  | {}", self.removed)?;
234        Ok(())
235    }
236
237    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
238        let json = serde_json::json!({
239            "total_symbols": self.total_symbols,
240            "embedded": self.embedded,
241            "skipped": self.skipped,
242            "removed": self.removed,
243        });
244        writeln!(w, "{}", serde_json::to_string_pretty(&json).unwrap())
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Displayable: GraphStats
250// ---------------------------------------------------------------------------
251
252impl Displayable for GraphStats {
253    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
254        write!(
255            w,
256            "Files: {} | Symbols: {} | Edges: {}",
257            self.files, self.symbols, self.edges
258        )?;
259        if let Some(ep) = self.entry_point_count {
260            write!(w, "\nEntry points: {ep}")?;
261            if let Some(ac) = self.avg_criticality {
262                write!(w, " | Avg criticality: {ac:.3}")?;
263            }
264        }
265        if let Some(cc) = self.clone_clusters {
266            write!(w, "\nClone clusters: {cc}")?;
267            if let Some(dp) = self.duplication_pct {
268                write!(w, " | Duplication: {dp:.1}%")?;
269            }
270            if let Some(ref md) = self.most_duplicated {
271                write!(w, " | Most duplicated: {md}")?;
272            }
273        }
274        if let Some(ar) = self.avg_risk {
275            write!(w, "\nAvg risk: {ar:.2}")?;
276            if let Some(p90) = self.p90_risk {
277                write!(w, " | P90 risk: {p90:.2}")?;
278            }
279        }
280        writeln!(w)
281    }
282
283    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
284        writeln!(w, "Metric  | Count")?;
285        writeln!(w, "--------+------")?;
286        writeln!(w, "Files   | {}", self.files)?;
287        writeln!(w, "Symbols | {}", self.symbols)?;
288        writeln!(w, "Edges   | {}", self.edges)?;
289        if let Some(ep) = self.entry_point_count {
290            writeln!(w, "Entry pts | {ep}")?;
291        }
292        if let Some(ac) = self.avg_criticality {
293            writeln!(w, "Avg crit  | {ac:.3}")?;
294        }
295        if let Some(cc) = self.clone_clusters {
296            writeln!(w, "Clones    | {cc} clusters")?;
297        }
298        if let Some(dp) = self.duplication_pct {
299            writeln!(w, "Dupl %    | {dp:.1}%")?;
300        }
301        if let Some(ref md) = self.most_duplicated {
302            writeln!(w, "Most dupl | {md}")?;
303        }
304        if let Some(ar) = self.avg_risk {
305            writeln!(w, "Avg risk  | {ar:.2}")?;
306        }
307        if let Some(p90) = self.p90_risk {
308            writeln!(w, "P90 risk  | {p90:.2}")?;
309        }
310        Ok(())
311    }
312
313    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
314        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
315        writeln!(w, "{json}")
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Displayable: FlowAnalysis
321// ---------------------------------------------------------------------------
322
323fn format_entry_point_summary(analysis: &FlowAnalysis) -> String {
324    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
325    for ep in &analysis.entry_points {
326        let label = match ep.kind {
327            EntryPointKind::Main => "main",
328            EntryPointKind::Test => "test",
329            EntryPointKind::HttpHandler => "http",
330            EntryPointKind::CliCommand => "cli",
331            EntryPointKind::PublicRoot => "public-root",
332        };
333        *counts.entry(label).or_default() += 1;
334    }
335    let parts: Vec<String> = counts.iter().map(|(k, v)| format!("{v} {k}")).collect();
336    parts.join(", ")
337}
338
339impl Displayable for FlowAnalysis {
340    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
341        let summary = format_entry_point_summary(self);
342        writeln!(
343            w,
344            "Entry points: {} detected ({})",
345            self.stats.total_entry_points, summary
346        )?;
347        writeln!(
348            w,
349            "Flows: {} total, showing {}",
350            self.stats.total_flows,
351            self.flows.len()
352        )?;
353        writeln!(w)?;
354        for (i, flow) in self.flows.iter().enumerate() {
355            let path_str = flow.path.join(" -> ");
356            let truncated = if flow.truncated { " [truncated]" } else { "" };
357            writeln!(
358                w,
359                "[{}] {} (depth {}){}",
360                i + 1,
361                path_str,
362                flow.depth,
363                truncated
364            )?;
365        }
366        Ok(())
367    }
368
369    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
370        writeln!(w, "# | Entry | Path | Depth | Truncated")?;
371        writeln!(w, "--+-------+------+-------+----------")?;
372        for (i, flow) in self.flows.iter().enumerate() {
373            writeln!(
374                w,
375                "{} | {} | {} | {} | {}",
376                i + 1,
377                flow.entry,
378                flow.path.join(" -> "),
379                flow.depth,
380                flow.truncated
381            )?;
382        }
383        Ok(())
384    }
385
386    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
387        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
388        writeln!(w, "{json}")
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Displayable: Vec<CriticalityScore>
394// ---------------------------------------------------------------------------
395
396impl Displayable for Vec<CriticalityScore> {
397    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
398        for (i, score) in self.iter().enumerate() {
399            let entry = if score.is_entry_point { "yes" } else { "no" };
400            writeln!(
401                w,
402                "{} {}  betweenness={:.3}  flows={}  entry={}",
403                i + 1,
404                score.qualified_name,
405                score.betweenness,
406                score.flow_count,
407                entry
408            )?;
409        }
410        Ok(())
411    }
412
413    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
414        writeln!(w, "# | Symbol | Betweenness | Flows | Entry?")?;
415        writeln!(w, "--+--------+-------------+-------+-------")?;
416        for (i, score) in self.iter().enumerate() {
417            let entry = if score.is_entry_point { "yes" } else { "no" };
418            writeln!(
419                w,
420                "{} | {} | {:.3} | {} | {}",
421                i + 1,
422                score.qualified_name,
423                score.betweenness,
424                score.flow_count,
425                entry
426            )?;
427        }
428        Ok(())
429    }
430
431    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
432        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
433        writeln!(w, "{json}")
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Displayable: ImpactReport
439// ---------------------------------------------------------------------------
440
441/// Helper: format affected nodes grouped by confidence, sorted by depth.
442fn fmt_affected_compact(affected: &[AffectedNode], w: &mut dyn Write) -> std::io::Result<()> {
443    let mut sorted: Vec<&AffectedNode> = affected.iter().collect();
444    sorted.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(a.depth.cmp(&b.depth)));
445    for node in sorted {
446        if node.path.is_empty() {
447            writeln!(
448                w,
449                "  [{:?}] {} (depth {})",
450                node.confidence, node.qualified_name, node.depth
451            )?;
452        } else {
453            writeln!(
454                w,
455                "  [{:?}] {} (depth {} via {})",
456                node.confidence,
457                node.qualified_name,
458                node.depth,
459                node.path.join(" -> ")
460            )?;
461        }
462    }
463    Ok(())
464}
465
466/// Helper: format affected nodes as table rows.
467fn fmt_affected_table(affected: &[AffectedNode], w: &mut dyn Write) -> std::io::Result<()> {
468    writeln!(w, "QualifiedName | Depth | Confidence | Path")?;
469    writeln!(w, "--------------+-------+------------+-----")?;
470    let mut sorted: Vec<&AffectedNode> = affected.iter().collect();
471    sorted.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(a.depth.cmp(&b.depth)));
472    for node in sorted {
473        writeln!(
474            w,
475            "{} | {} | {:?} | {}",
476            node.qualified_name,
477            node.depth,
478            node.confidence,
479            node.path.join(" -> ")
480        )?;
481    }
482    Ok(())
483}
484
485impl Displayable for ImpactReport {
486    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
487        writeln!(
488            w,
489            "Impact: {} affected symbols (depth: {}, min_confidence: {:?})",
490            self.affected.len(),
491            self.depth,
492            self.min_confidence
493        )?;
494        fmt_affected_compact(&self.affected, w)
495    }
496
497    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
498        fmt_affected_table(&self.affected, w)
499    }
500
501    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
502        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
503        writeln!(w, "{json}")
504    }
505}
506
507// ---------------------------------------------------------------------------
508// Displayable: DiffImpactReport
509// ---------------------------------------------------------------------------
510
511impl Displayable for DiffImpactReport {
512    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
513        writeln!(w, "Changed symbols ({}):", self.changed_symbols.len())?;
514        for s in &self.changed_symbols {
515            let loc = &s.location;
516            writeln!(
517                w,
518                "  {} {:?} {}:{}-{}",
519                s.name,
520                s.kind,
521                loc.file.display(),
522                loc.line_start,
523                loc.line_end
524            )?;
525        }
526        writeln!(w, "Impact:")?;
527        writeln!(
528            w,
529            "  {} affected symbols (depth: {}, min_confidence: {:?})",
530            self.impact.affected.len(),
531            self.impact.depth,
532            self.impact.min_confidence
533        )?;
534        fmt_affected_compact(&self.impact.affected, w)
535    }
536
537    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
538        writeln!(w, "Changed Symbols:")?;
539        writeln!(w, "Name | Kind | File | Lines")?;
540        writeln!(w, "-----+------+------+------")?;
541        for s in &self.changed_symbols {
542            let loc = &s.location;
543            writeln!(
544                w,
545                "{} | {:?} | {} | {}-{}",
546                s.name,
547                s.kind,
548                loc.file.display(),
549                loc.line_start,
550                loc.line_end
551            )?;
552        }
553        writeln!(w)?;
554        writeln!(w, "Impact:")?;
555        fmt_affected_table(&self.impact.affected, w)
556    }
557
558    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
559        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
560        writeln!(w, "{json}")
561    }
562}
563
564// ---------------------------------------------------------------------------
565// Displayable: CloneAnalysis
566// ---------------------------------------------------------------------------
567
568impl Displayable for CloneAnalysis {
569    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
570        writeln!(
571            w,
572            "{} clone clusters, {:.1}% duplication ({}/{} symbols)",
573            self.clusters.len(),
574            self.duplication_pct,
575            self.symbols_in_clones,
576            self.total_symbols_analyzed
577        )?;
578        if let Some(ref most) = self.most_duplicated {
579            writeln!(w, "most duplicated: {most}")?;
580        }
581        writeln!(w)?;
582        for cluster in &self.clusters {
583            writeln!(
584                w,
585                "#{} {:?}  members={}  avg_sim={:.2}  [{}]",
586                cluster.id,
587                cluster.clone_type,
588                cluster.members.len(),
589                cluster.avg_similarity,
590                cluster.members.join(", ")
591            )?;
592        }
593        Ok(())
594    }
595
596    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
597        writeln!(
598            w,
599            "Clone Analysis: {} clusters, {:.1}% duplication",
600            self.clusters.len(),
601            self.duplication_pct
602        )?;
603        writeln!(w)?;
604        writeln!(w, "# | Type | Members | Avg Similarity | Representative")?;
605        writeln!(w, "--+------+---------+----------------+---------------")?;
606        for c in &self.clusters {
607            writeln!(
608                w,
609                "{} | {:?} | {} | {:.3} | {}",
610                c.id,
611                c.clone_type,
612                c.members.len(),
613                c.avg_similarity,
614                c.representative
615            )?;
616        }
617        Ok(())
618    }
619
620    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
621        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
622        writeln!(w, "{json}")
623    }
624}
625
626// ---------------------------------------------------------------------------
627// Displayable: Vec<CloneCluster>
628// ---------------------------------------------------------------------------
629
630impl Displayable for Vec<CloneCluster> {
631    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
632        for cluster in self {
633            writeln!(
634                w,
635                "Cluster #{} ({:?}, avg_sim={:.2}):",
636                cluster.id, cluster.clone_type, cluster.avg_similarity
637            )?;
638            for member in &cluster.members {
639                // Extract file path from qualified name (e.g., "src/foo.rs::bar" -> "src/foo.rs")
640                let file = member.split("::").next().unwrap_or(member);
641                writeln!(w, "  {member}  ({file})")?;
642            }
643            if !cluster.intra_matches.is_empty() {
644                writeln!(w, "  pairs:")?;
645                for m in &cluster.intra_matches {
646                    writeln!(
647                        w,
648                        "    {} <-> {}  sim={:.3} ({:?})",
649                        m.source, m.target, m.similarity, m.clone_type
650                    )?;
651                }
652            }
653        }
654        Ok(())
655    }
656
657    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
658        for cluster in self {
659            writeln!(
660                w,
661                "Cluster #{} — {:?} — avg similarity: {:.3}",
662                cluster.id, cluster.clone_type, cluster.avg_similarity
663            )?;
664            writeln!(w, "Member | File")?;
665            writeln!(w, "-------+-----")?;
666            for member in &cluster.members {
667                let file = member.split("::").next().unwrap_or(member);
668                writeln!(w, "{member} | {file}")?;
669            }
670            if !cluster.intra_matches.is_empty() {
671                writeln!(w)?;
672                writeln!(w, "Source | Target | Similarity | Type")?;
673                writeln!(w, "-------+--------+------------+-----")?;
674                for m in &cluster.intra_matches {
675                    writeln!(
676                        w,
677                        "{} | {} | {:.3} | {:?}",
678                        m.source, m.target, m.similarity, m.clone_type
679                    )?;
680                }
681            }
682            writeln!(w)?;
683        }
684        Ok(())
685    }
686
687    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
688        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
689        writeln!(w, "{json}")
690    }
691}
692
693// ---------------------------------------------------------------------------
694// Displayable: RiskAnalysis (file-level view)
695// ---------------------------------------------------------------------------
696
697impl Displayable for RiskAnalysis {
698    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
699        writeln!(
700            w,
701            "Files by risk (top {} of {}):",
702            self.file_scores.len(),
703            self.stats.files_scored
704        )?;
705        writeln!(w, "#  File                              Risk   Driver")?;
706        for (i, f) in self.file_scores.iter().enumerate() {
707            writeln!(
708                w,
709                "{:<2} {:<34} {:.2}   {}",
710                i + 1,
711                f.path.display(),
712                f.composite,
713                f.highest_symbol
714            )?;
715        }
716        Ok(())
717    }
718
719    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
720        writeln!(w, "# | File | Risk | Driver")?;
721        writeln!(w, "--+------+------+-------")?;
722        for (i, f) in self.file_scores.iter().enumerate() {
723            writeln!(
724                w,
725                "{} | {} | {:.2} | {}",
726                i + 1,
727                f.path.display(),
728                f.composite,
729                f.highest_symbol
730            )?;
731        }
732        Ok(())
733    }
734
735    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
736        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
737        writeln!(w, "{json}")
738    }
739}
740
741// ---------------------------------------------------------------------------
742// Displayable: Vec<RiskScore> (symbol list)
743// ---------------------------------------------------------------------------
744
745impl Displayable for Vec<RiskScore> {
746    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
747        writeln!(
748            w,
749            "#  Symbol                             Risk  Crit  Coup  Test  Sec"
750        )?;
751        for (i, s) in self.iter().enumerate() {
752            writeln!(
753                w,
754                "{:<2} {:<34} {:.2}  {:.2}  {:.2}  {:.2}  {:.2}",
755                i + 1,
756                s.qualified_name,
757                s.composite,
758                s.factors.criticality,
759                s.factors.coupling,
760                s.factors.test_gap,
761                s.factors.sensitivity,
762            )?;
763        }
764        Ok(())
765    }
766
767    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
768        writeln!(w, "# | Symbol | Risk | Crit | Coup | Test | Sec")?;
769        writeln!(w, "--+--------+------+------+------+------+----")?;
770        for (i, s) in self.iter().enumerate() {
771            writeln!(
772                w,
773                "{} | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2}",
774                i + 1,
775                s.qualified_name,
776                s.composite,
777                s.factors.criticality,
778                s.factors.coupling,
779                s.factors.test_gap,
780                s.factors.sensitivity,
781            )?;
782        }
783        Ok(())
784    }
785
786    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
787        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
788        writeln!(w, "{json}")
789    }
790}
791
792// ---------------------------------------------------------------------------
793// Displayable: RiskScoreDetail (single target with context — AC3)
794// ---------------------------------------------------------------------------
795
796impl Displayable for RiskScoreDetail {
797    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
798        let s = &self.score;
799        writeln!(w, "{}  risk = {:.2}", s.qualified_name, s.composite)?;
800        writeln!(
801            w,
802            "  criticality:  {:.2}  (betweenness centrality)",
803            s.factors.criticality
804        )?;
805        writeln!(
806            w,
807            "  coupling:     {:.2}  (degree centrality)",
808            s.factors.coupling
809        )?;
810        let test_label = if s.factors.test_gap > 0.5 {
811            "no TestedBy edges"
812        } else {
813            "has TestedBy edges"
814        };
815        writeln!(
816            w,
817            "  test_gap:     {:.2}  ({})",
818            s.factors.test_gap, test_label
819        )?;
820        let sec_label = if self.matched_patterns.is_empty() {
821            "no match".to_string()
822        } else {
823            format!("matches: {}", self.matched_patterns.join(", "))
824        };
825        writeln!(
826            w,
827            "  sensitivity:  {:.2}  ({})",
828            s.factors.sensitivity, sec_label
829        )?;
830        let wt = &self.weights;
831        writeln!(
832            w,
833            "  weights: crit={:.2} coup={:.2} test={:.2} sec={:.2}",
834            wt.criticality, wt.coupling, wt.test_gap, wt.sensitivity
835        )
836    }
837
838    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
839        let s = &self.score;
840        writeln!(w, "Factor | Value | Description")?;
841        writeln!(w, "-------+-------+------------")?;
842        writeln!(
843            w,
844            "criticality | {:.2} | betweenness centrality",
845            s.factors.criticality
846        )?;
847        writeln!(
848            w,
849            "coupling | {:.2} | degree centrality",
850            s.factors.coupling
851        )?;
852        writeln!(w, "test_gap | {:.2} | test coverage", s.factors.test_gap)?;
853        let sec_label = if self.matched_patterns.is_empty() {
854            "no match".to_string()
855        } else {
856            format!("matches: {}", self.matched_patterns.join(", "))
857        };
858        writeln!(
859            w,
860            "sensitivity | {:.2} | {}",
861            s.factors.sensitivity, sec_label
862        )?;
863        writeln!(w, "COMPOSITE | {:.2} |", s.composite)?;
864        let wt = &self.weights;
865        writeln!(
866            w,
867            "weights | crit={:.2} coup={:.2} test={:.2} sec={:.2}",
868            wt.criticality, wt.coupling, wt.test_gap, wt.sensitivity
869        )
870    }
871
872    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
873        let json = serde_json::to_string_pretty(&self.score).map_err(std::io::Error::other)?;
874        writeln!(w, "{json}")
875    }
876}
877
878// ---------------------------------------------------------------------------
879// Displayable: CommunityAnalysis
880// ---------------------------------------------------------------------------
881
882impl Displayable for CommunityAnalysis {
883    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
884        writeln!(
885            w,
886            "Communities: {} (modularity: {:.2})",
887            self.stats.count, self.modularity
888        )?;
889        writeln!(w)?;
890        for c in &self.communities {
891            writeln!(
892                w,
893                " #{}  {} ({} symbols, {} internal / {} boundary edges)",
894                c.id,
895                c.name,
896                c.members.len(),
897                c.internal_edges,
898                c.boundary_edges
899            )?;
900            let preview: Vec<&str> = c.members.iter().take(3).map(|s| s.as_str()).collect();
901            writeln!(
902                w,
903                "     {}{}",
904                preview.join(", "),
905                if c.members.len() > 3 { ", ..." } else { "" }
906            )?;
907        }
908        Ok(())
909    }
910
911    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
912        writeln!(
913            w,
914            " ID  Name            Size  Internal  Boundary  Modularity"
915        )?;
916        for c in &self.communities {
917            writeln!(
918                w,
919                "{:>3}  {:<15} {:>4}  {:>8}  {:>8}  {:>10.2}",
920                c.id,
921                c.name,
922                c.members.len(),
923                c.internal_edges,
924                c.boundary_edges,
925                c.modularity_contribution
926            )?;
927        }
928        Ok(())
929    }
930
931    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
932        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
933        writeln!(w, "{json}")
934    }
935}
936
937// ---------------------------------------------------------------------------
938// Displayable: Vec<Community> (detail view)
939// ---------------------------------------------------------------------------
940
941impl Displayable for Vec<Community> {
942    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
943        for c in self {
944            writeln!(
945                w,
946                "Community #{}: {} ({} symbols)",
947                c.id,
948                c.name,
949                c.members.len()
950            )?;
951            writeln!(
952                w,
953                "Modularity contribution: {:.2}",
954                c.modularity_contribution
955            )?;
956            writeln!(
957                w,
958                "Internal edges: {} | Boundary edges: {}",
959                c.internal_edges, c.boundary_edges
960            )?;
961            writeln!(w)?;
962            writeln!(w, "Members:")?;
963            for m in &c.members {
964                writeln!(w, "  {m}")?;
965            }
966        }
967        Ok(())
968    }
969
970    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
971        for c in self {
972            writeln!(
973                w,
974                "Community #{}: {} ({} symbols)",
975                c.id,
976                c.name,
977                c.members.len()
978            )?;
979            writeln!(w)?;
980            writeln!(w, "Member")?;
981            writeln!(w, "------")?;
982            for m in &c.members {
983                writeln!(w, "{m}")?;
984            }
985        }
986        Ok(())
987    }
988
989    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
990        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
991        writeln!(w, "{json}")
992    }
993}
994
995// ---------------------------------------------------------------------------
996// Displayable: DeadCodeAnalysis
997// ---------------------------------------------------------------------------
998
999impl Displayable for DeadCodeAnalysis {
1000    fn fmt_compact(&self, w: &mut dyn Write) -> std::io::Result<()> {
1001        writeln!(
1002            w,
1003            "Dead code: {} symbols (of {} total, {:.1}%)\n",
1004            self.summary.dead_count, self.summary.total_symbols, self.summary.dead_percentage,
1005        )?;
1006        for ds in &self.dead_symbols {
1007            let short_name = ds
1008                .qualified_name
1009                .split("::")
1010                .last()
1011                .unwrap_or(&ds.qualified_name);
1012            writeln!(
1013                w,
1014                "  {}:{}    {}    {:?}",
1015                ds.file_path, ds.line, short_name, ds.kind,
1016            )?;
1017        }
1018        Ok(())
1019    }
1020
1021    fn fmt_table(&self, w: &mut dyn Write) -> std::io::Result<()> {
1022        writeln!(w, "File | Line | Symbol | Kind | Visibility")?;
1023        writeln!(w, "-----+------+--------+------+-----------")?;
1024        for ds in &self.dead_symbols {
1025            writeln!(
1026                w,
1027                "{} | {} | {} | {:?} | {:?}",
1028                ds.file_path, ds.line, ds.qualified_name, ds.kind, ds.visibility,
1029            )?;
1030        }
1031        writeln!(
1032            w,
1033            "\nTotal: {} dead of {} ({:.1}%), {} excluded",
1034            self.summary.dead_count,
1035            self.summary.total_symbols,
1036            self.summary.dead_percentage,
1037            self.summary.excluded_count,
1038        )
1039    }
1040
1041    fn fmt_json(&self, w: &mut dyn Write) -> std::io::Result<()> {
1042        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
1043        writeln!(w, "{json}")
1044    }
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use domain::model::{Confidence, EdgeKind, Location, SymbolKind, Visibility};
1051    use std::time::Duration;
1052
1053    fn sample_stats() -> IndexStats {
1054        IndexStats {
1055            files_indexed: 42,
1056            symbols_extracted: 128,
1057            edges_created: 256,
1058            duration: Duration::from_secs_f64(1.5),
1059        }
1060    }
1061
1062    #[test]
1063    fn output_format_from_flags_json() {
1064        assert_eq!(OutputFormat::from_flags(true, false), OutputFormat::Json);
1065    }
1066
1067    #[test]
1068    fn output_format_from_flags_table() {
1069        assert_eq!(OutputFormat::from_flags(false, true), OutputFormat::Table);
1070    }
1071
1072    #[test]
1073    fn output_format_from_flags_compact() {
1074        assert_eq!(
1075            OutputFormat::from_flags(false, false),
1076            OutputFormat::Compact
1077        );
1078    }
1079
1080    #[test]
1081    fn output_format_json_takes_precedence() {
1082        assert_eq!(OutputFormat::from_flags(true, true), OutputFormat::Json);
1083    }
1084
1085    #[test]
1086    fn index_stats_compact_format() {
1087        let stats = sample_stats();
1088        let mut buf = Vec::new();
1089        stats.fmt_compact(&mut buf).unwrap();
1090        let s = String::from_utf8(buf).unwrap();
1091        assert!(s.contains("42 files"));
1092        assert!(s.contains("128 symbols"));
1093        assert!(s.contains("256 edges"));
1094        assert!(s.contains("1.5s"));
1095    }
1096
1097    #[test]
1098    fn index_stats_json_format() {
1099        let stats = sample_stats();
1100        let mut buf = Vec::new();
1101        stats.fmt_json(&mut buf).unwrap();
1102        let s = String::from_utf8(buf).unwrap();
1103        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1104        assert_eq!(parsed["files_indexed"], 42);
1105        assert_eq!(parsed["symbols_extracted"], 128);
1106        assert_eq!(parsed["edges_created"], 256);
1107    }
1108
1109    #[test]
1110    fn index_stats_table_format() {
1111        let stats = sample_stats();
1112        let mut buf = Vec::new();
1113        stats.fmt_table(&mut buf).unwrap();
1114        let s = String::from_utf8(buf).unwrap();
1115        assert!(s.contains("Files indexed"));
1116        assert!(s.contains("42"));
1117        assert!(s.contains("Symbols"));
1118        assert!(s.contains("128"));
1119    }
1120
1121    // -----------------------------------------------------------------------
1122    // Helpers for new types
1123    // -----------------------------------------------------------------------
1124
1125    fn sample_symbol() -> SymbolNode {
1126        SymbolNode {
1127            name: "foo".into(),
1128            qualified_name: "src/lib.rs::foo".into(),
1129            kind: SymbolKind::Function,
1130            location: Location {
1131                file: "src/lib.rs".into(),
1132                line_start: 10,
1133                line_end: 20,
1134                col_start: 0,
1135                col_end: 1,
1136            },
1137            visibility: Visibility::Public,
1138            is_exported: true,
1139            is_async: false,
1140            is_test: false,
1141            decorators: vec![],
1142            signature: None,
1143        }
1144    }
1145
1146    fn sample_find_results() -> Vec<FindResult> {
1147        vec![FindResult {
1148            symbol: sample_symbol(),
1149            callers: vec!["bar".into()],
1150            callees: vec!["baz".into(), "qux".into()],
1151            tested_by: vec!["test_foo".into()],
1152        }]
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // Vec<FindResult> tests
1157    // -----------------------------------------------------------------------
1158
1159    #[test]
1160    fn find_result_compact_format() {
1161        let results = sample_find_results();
1162        let mut buf = Vec::new();
1163        results.fmt_compact(&mut buf).unwrap();
1164        let s = String::from_utf8(buf).unwrap();
1165        assert!(s.contains("foo Function src/lib.rs:10-20"));
1166        assert!(s.contains("-> calls: baz, qux"));
1167        assert!(s.contains("-> tested_by: test_foo"));
1168        assert!(s.contains("<- callers: bar"));
1169    }
1170
1171    #[test]
1172    fn find_result_json_format() {
1173        let results = sample_find_results();
1174        let mut buf = Vec::new();
1175        results.fmt_json(&mut buf).unwrap();
1176        let s = String::from_utf8(buf).unwrap();
1177        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1178        assert!(parsed.is_array());
1179        assert_eq!(parsed[0]["symbol"]["name"], "foo");
1180        assert_eq!(parsed[0]["callers"][0], "bar");
1181        assert_eq!(parsed[0]["callees"][0], "baz");
1182    }
1183
1184    #[test]
1185    fn find_result_table_format() {
1186        let results = sample_find_results();
1187        let mut buf = Vec::new();
1188        results.fmt_table(&mut buf).unwrap();
1189        let s = String::from_utf8(buf).unwrap();
1190        assert!(s.contains("Name | Kind | File | Lines | Callers | Callees"));
1191        assert!(s.contains("foo"));
1192        assert!(s.contains("bar"));
1193        assert!(s.contains("baz, qux"));
1194    }
1195
1196    #[test]
1197    fn find_result_compact_empty_relations() {
1198        let results = vec![FindResult {
1199            symbol: sample_symbol(),
1200            callers: vec![],
1201            callees: vec![],
1202            tested_by: vec![],
1203        }];
1204        let mut buf = Vec::new();
1205        results.fmt_compact(&mut buf).unwrap();
1206        let s = String::from_utf8(buf).unwrap();
1207        assert!(s.contains("foo Function src/lib.rs:10-20"));
1208        assert!(!s.contains("-> calls:"));
1209        assert!(!s.contains("-> tested_by:"));
1210        assert!(!s.contains("<- callers:"));
1211    }
1212
1213    // -----------------------------------------------------------------------
1214    // Vec<Reference> tests
1215    // -----------------------------------------------------------------------
1216
1217    fn sample_references() -> Vec<Reference> {
1218        vec![
1219            Reference {
1220                symbol: "src/lib.rs::bar".into(),
1221                edge_kind: EdgeKind::Calls,
1222                location: None,
1223            },
1224            Reference {
1225                symbol: "src/lib.rs::baz".into(),
1226                edge_kind: EdgeKind::ImportsFrom,
1227                location: None,
1228            },
1229        ]
1230    }
1231
1232    #[test]
1233    fn reference_compact_format() {
1234        let refs = sample_references();
1235        let mut buf = Vec::new();
1236        refs.fmt_compact(&mut buf).unwrap();
1237        let s = String::from_utf8(buf).unwrap();
1238        assert!(s.contains("src/lib.rs::bar (Calls)"));
1239        assert!(s.contains("src/lib.rs::baz (ImportsFrom)"));
1240    }
1241
1242    #[test]
1243    fn reference_json_format() {
1244        let refs = sample_references();
1245        let mut buf = Vec::new();
1246        refs.fmt_json(&mut buf).unwrap();
1247        let s = String::from_utf8(buf).unwrap();
1248        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1249        assert!(parsed.is_array());
1250        assert_eq!(parsed[0]["symbol"], "src/lib.rs::bar");
1251        assert_eq!(parsed[0]["edge_kind"], "Calls");
1252    }
1253
1254    #[test]
1255    fn reference_table_format() {
1256        let refs = sample_references();
1257        let mut buf = Vec::new();
1258        refs.fmt_table(&mut buf).unwrap();
1259        let s = String::from_utf8(buf).unwrap();
1260        assert!(s.contains("Symbol | EdgeKind"));
1261        assert!(s.contains("src/lib.rs::bar | Calls"));
1262    }
1263
1264    // -----------------------------------------------------------------------
1265    // Vec<SearchResult> tests
1266    // -----------------------------------------------------------------------
1267
1268    fn sample_search_results() -> Vec<SearchResult> {
1269        vec![SearchResult {
1270            qualified_name: "src/lib.rs::foo".into(),
1271            name: "foo".into(),
1272            kind: SymbolKind::Function,
1273            file_path: "src/lib.rs".into(),
1274            score: 0.95,
1275            score_source: None,
1276        }]
1277    }
1278
1279    #[test]
1280    fn search_result_compact_format() {
1281        let results = sample_search_results();
1282        let mut buf = Vec::new();
1283        results.fmt_compact(&mut buf).unwrap();
1284        let s = String::from_utf8(buf).unwrap();
1285        assert!(s.contains("src/lib.rs::foo Function src/lib.rs score=0.95"));
1286    }
1287
1288    #[test]
1289    fn search_result_json_format() {
1290        let results = sample_search_results();
1291        let mut buf = Vec::new();
1292        results.fmt_json(&mut buf).unwrap();
1293        let s = String::from_utf8(buf).unwrap();
1294        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1295        assert!(parsed.is_array());
1296        assert_eq!(parsed[0]["qualified_name"], "src/lib.rs::foo");
1297        assert_eq!(parsed[0]["score"], 0.95);
1298    }
1299
1300    #[test]
1301    fn search_result_table_format() {
1302        let results = sample_search_results();
1303        let mut buf = Vec::new();
1304        results.fmt_table(&mut buf).unwrap();
1305        let s = String::from_utf8(buf).unwrap();
1306        assert!(s.contains("QualifiedName | Kind | File | Score"));
1307        assert!(s.contains("src/lib.rs::foo | Function"));
1308    }
1309
1310    // -----------------------------------------------------------------------
1311    // GraphStats tests
1312    // -----------------------------------------------------------------------
1313
1314    fn sample_graph_stats() -> GraphStats {
1315        GraphStats {
1316            files: 10,
1317            symbols: 50,
1318            edges: 100,
1319            entry_point_count: None,
1320            avg_criticality: None,
1321            clone_clusters: None,
1322            duplication_pct: None,
1323            most_duplicated: None,
1324            avg_risk: None,
1325            p90_risk: None,
1326            community_count: None,
1327            modularity: None,
1328        }
1329    }
1330
1331    #[test]
1332    fn graph_stats_compact_format() {
1333        let stats = sample_graph_stats();
1334        let mut buf = Vec::new();
1335        stats.fmt_compact(&mut buf).unwrap();
1336        let s = String::from_utf8(buf).unwrap();
1337        assert!(s.contains("Files: 10"));
1338        assert!(s.contains("Symbols: 50"));
1339        assert!(s.contains("Edges: 100"));
1340    }
1341
1342    #[test]
1343    fn graph_stats_json_format() {
1344        let stats = sample_graph_stats();
1345        let mut buf = Vec::new();
1346        stats.fmt_json(&mut buf).unwrap();
1347        let s = String::from_utf8(buf).unwrap();
1348        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1349        assert_eq!(parsed["files"], 10);
1350        assert_eq!(parsed["symbols"], 50);
1351        assert_eq!(parsed["edges"], 100);
1352    }
1353
1354    #[test]
1355    fn graph_stats_table_format() {
1356        let stats = sample_graph_stats();
1357        let mut buf = Vec::new();
1358        stats.fmt_table(&mut buf).unwrap();
1359        let s = String::from_utf8(buf).unwrap();
1360        assert!(s.contains("Metric"));
1361        assert!(s.contains("Files"));
1362        assert!(s.contains("10"));
1363        assert!(s.contains("Symbols"));
1364        assert!(s.contains("50"));
1365    }
1366
1367    // -----------------------------------------------------------------------
1368    // ImpactReport tests
1369    // -----------------------------------------------------------------------
1370
1371    fn sample_impact_report() -> ImpactReport {
1372        ImpactReport {
1373            targets: vec![],
1374            affected: vec![
1375                AffectedNode {
1376                    qualified_name: "src/a.rs::alpha".into(),
1377                    depth: 1,
1378                    confidence: Confidence::High,
1379                    path: vec!["foo".into(), "alpha".into()],
1380                },
1381                AffectedNode {
1382                    qualified_name: "src/b.rs::beta".into(),
1383                    depth: 2,
1384                    confidence: Confidence::Medium,
1385                    path: vec![],
1386                },
1387            ],
1388            depth: 3,
1389            min_confidence: Confidence::Medium,
1390        }
1391    }
1392
1393    #[test]
1394    fn impact_report_compact_format() {
1395        let report = sample_impact_report();
1396        let mut buf = Vec::new();
1397        report.fmt_compact(&mut buf).unwrap();
1398        let s = String::from_utf8(buf).unwrap();
1399        assert!(s.contains("Impact: 2 affected symbols (depth: 3, min_confidence: Medium)"));
1400        assert!(s.contains("[High] src/a.rs::alpha (depth 1 via foo -> alpha)"));
1401        assert!(s.contains("[Medium] src/b.rs::beta (depth 2)"));
1402    }
1403
1404    #[test]
1405    fn impact_report_json_format() {
1406        let report = sample_impact_report();
1407        let mut buf = Vec::new();
1408        report.fmt_json(&mut buf).unwrap();
1409        let s = String::from_utf8(buf).unwrap();
1410        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1411        assert_eq!(parsed["depth"], 3);
1412        assert_eq!(parsed["affected"].as_array().unwrap().len(), 2);
1413        assert_eq!(parsed["affected"][0]["qualified_name"], "src/a.rs::alpha");
1414    }
1415
1416    #[test]
1417    fn impact_report_table_format() {
1418        let report = sample_impact_report();
1419        let mut buf = Vec::new();
1420        report.fmt_table(&mut buf).unwrap();
1421        let s = String::from_utf8(buf).unwrap();
1422        assert!(s.contains("QualifiedName | Depth | Confidence | Path"));
1423        assert!(s.contains("src/a.rs::alpha | 1 | High"));
1424        assert!(s.contains("src/b.rs::beta | 2 | Medium"));
1425    }
1426
1427    #[test]
1428    fn impact_report_sorted_by_confidence_then_depth() {
1429        let report = ImpactReport {
1430            targets: vec![],
1431            affected: vec![
1432                AffectedNode {
1433                    qualified_name: "low_deep".into(),
1434                    depth: 5,
1435                    confidence: Confidence::Low,
1436                    path: vec![],
1437                },
1438                AffectedNode {
1439                    qualified_name: "high_shallow".into(),
1440                    depth: 1,
1441                    confidence: Confidence::High,
1442                    path: vec![],
1443                },
1444                AffectedNode {
1445                    qualified_name: "high_deep".into(),
1446                    depth: 3,
1447                    confidence: Confidence::High,
1448                    path: vec![],
1449                },
1450            ],
1451            depth: 5,
1452            min_confidence: Confidence::Low,
1453        };
1454        let mut buf = Vec::new();
1455        report.fmt_compact(&mut buf).unwrap();
1456        let s = String::from_utf8(buf).unwrap();
1457        let high_shallow_pos = s.find("high_shallow").unwrap();
1458        let high_deep_pos = s.find("high_deep").unwrap();
1459        let low_deep_pos = s.find("low_deep").unwrap();
1460        assert!(
1461            high_shallow_pos < high_deep_pos,
1462            "High confidence should come before lower"
1463        );
1464        assert!(
1465            high_deep_pos < low_deep_pos,
1466            "High confidence should come before Low"
1467        );
1468    }
1469
1470    // -----------------------------------------------------------------------
1471    // DiffImpactReport tests
1472    // -----------------------------------------------------------------------
1473
1474    fn sample_diff_impact_report() -> DiffImpactReport {
1475        DiffImpactReport {
1476            changed_symbols: vec![sample_symbol()],
1477            impact: sample_impact_report(),
1478        }
1479    }
1480
1481    #[test]
1482    fn diff_impact_report_compact_format() {
1483        let report = sample_diff_impact_report();
1484        let mut buf = Vec::new();
1485        report.fmt_compact(&mut buf).unwrap();
1486        let s = String::from_utf8(buf).unwrap();
1487        assert!(s.contains("Changed symbols (1):"));
1488        assert!(s.contains("foo Function src/lib.rs:10-20"));
1489        assert!(s.contains("Impact:"));
1490        assert!(s.contains("2 affected symbols"));
1491        assert!(s.contains("[High] src/a.rs::alpha"));
1492    }
1493
1494    #[test]
1495    fn diff_impact_report_json_format() {
1496        let report = sample_diff_impact_report();
1497        let mut buf = Vec::new();
1498        report.fmt_json(&mut buf).unwrap();
1499        let s = String::from_utf8(buf).unwrap();
1500        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1501        assert_eq!(parsed["changed_symbols"].as_array().unwrap().len(), 1);
1502        assert_eq!(parsed["changed_symbols"][0]["name"], "foo");
1503        assert_eq!(parsed["impact"]["depth"], 3);
1504    }
1505
1506    #[test]
1507    fn diff_impact_report_table_format() {
1508        let report = sample_diff_impact_report();
1509        let mut buf = Vec::new();
1510        report.fmt_table(&mut buf).unwrap();
1511        let s = String::from_utf8(buf).unwrap();
1512        assert!(s.contains("Changed Symbols:"));
1513        assert!(s.contains("Name | Kind | File | Lines"));
1514        assert!(s.contains("foo"));
1515        assert!(s.contains("Impact:"));
1516        assert!(s.contains("QualifiedName | Depth | Confidence | Path"));
1517    }
1518
1519    // -----------------------------------------------------------------------
1520    // FlowAnalysis tests
1521    // -----------------------------------------------------------------------
1522
1523    fn sample_flow_analysis() -> FlowAnalysis {
1524        use domain::model::{EntryPoint, ExecutionFlow, FlowStats};
1525        FlowAnalysis {
1526            entry_points: vec![EntryPoint {
1527                qualified_name: "main".into(),
1528                kind: EntryPointKind::Main,
1529                confidence: 1.0,
1530            }],
1531            flows: vec![ExecutionFlow {
1532                entry: "main".into(),
1533                path: vec!["main".into(), "db.connect".into()],
1534                depth: 2,
1535                truncated: false,
1536            }],
1537            criticality: vec![],
1538            stats: FlowStats {
1539                total_entry_points: 1,
1540                total_flows: 1,
1541                max_depth: 2,
1542                avg_depth: 2.0,
1543            },
1544        }
1545    }
1546
1547    fn sample_criticality() -> Vec<CriticalityScore> {
1548        vec![CriticalityScore {
1549            qualified_name: "db.query".into(),
1550            betweenness: 0.847,
1551            flow_count: 312,
1552            is_entry_point: false,
1553        }]
1554    }
1555
1556    #[test]
1557    fn flow_analysis_compact_format() {
1558        let analysis = sample_flow_analysis();
1559        let mut buf = Vec::new();
1560        analysis.fmt_compact(&mut buf).unwrap();
1561        let s = String::from_utf8(buf).unwrap();
1562        assert!(s.contains("Entry points: 1"));
1563        assert!(s.contains("main"));
1564        assert!(s.contains("db.connect"));
1565    }
1566
1567    #[test]
1568    fn flow_analysis_table_format() {
1569        let analysis = sample_flow_analysis();
1570        let mut buf = Vec::new();
1571        analysis.fmt_table(&mut buf).unwrap();
1572        let s = String::from_utf8(buf).unwrap();
1573        assert!(s.contains("Entry"));
1574        assert!(s.contains("Path"));
1575        assert!(s.contains("main"));
1576    }
1577
1578    #[test]
1579    fn flow_analysis_json_format() {
1580        let analysis = sample_flow_analysis();
1581        let mut buf = Vec::new();
1582        analysis.fmt_json(&mut buf).unwrap();
1583        let s = String::from_utf8(buf).unwrap();
1584        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1585        assert_eq!(parsed["stats"]["total_flows"], 1);
1586        assert_eq!(parsed["flows"][0]["entry"], "main");
1587    }
1588
1589    #[test]
1590    fn criticality_compact_format() {
1591        let scores = sample_criticality();
1592        let mut buf = Vec::new();
1593        scores.fmt_compact(&mut buf).unwrap();
1594        let s = String::from_utf8(buf).unwrap();
1595        assert!(s.contains("db.query"));
1596        assert!(s.contains("0.847"));
1597    }
1598
1599    #[test]
1600    fn criticality_table_format() {
1601        let scores = sample_criticality();
1602        let mut buf = Vec::new();
1603        scores.fmt_table(&mut buf).unwrap();
1604        let s = String::from_utf8(buf).unwrap();
1605        assert!(s.contains("Symbol"));
1606        assert!(s.contains("Betweenness"));
1607        assert!(s.contains("db.query"));
1608        assert!(s.contains("0.847"));
1609        assert!(s.contains("312"));
1610    }
1611
1612    #[test]
1613    fn criticality_json_format() {
1614        let scores = sample_criticality();
1615        let mut buf = Vec::new();
1616        scores.fmt_json(&mut buf).unwrap();
1617        let s = String::from_utf8(buf).unwrap();
1618        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1619        assert!(parsed.is_array());
1620        assert_eq!(parsed[0]["qualified_name"], "db.query");
1621        assert_eq!(parsed[0]["betweenness"], 0.847);
1622    }
1623
1624    // -----------------------------------------------------------------------
1625    // GraphStats with flow fields tests (T07)
1626    // -----------------------------------------------------------------------
1627
1628    #[test]
1629    fn graph_stats_compact_with_flow_fields() {
1630        let stats = GraphStats {
1631            files: 234,
1632            symbols: 1892,
1633            edges: 5431,
1634            entry_point_count: Some(12),
1635            avg_criticality: Some(0.034),
1636            clone_clusters: None,
1637            duplication_pct: None,
1638            most_duplicated: None,
1639            avg_risk: None,
1640            p90_risk: None,
1641            community_count: None,
1642            modularity: None,
1643        };
1644        let mut buf = Vec::new();
1645        stats.fmt_compact(&mut buf).unwrap();
1646        let s = String::from_utf8(buf).unwrap();
1647        assert!(s.contains("Entry points: 12"));
1648        assert!(s.contains("Avg criticality: 0.034"));
1649    }
1650
1651    #[test]
1652    fn graph_stats_compact_without_flow_fields() {
1653        let stats = GraphStats {
1654            files: 10,
1655            symbols: 50,
1656            edges: 100,
1657            entry_point_count: None,
1658            avg_criticality: None,
1659            clone_clusters: None,
1660            duplication_pct: None,
1661            most_duplicated: None,
1662            avg_risk: None,
1663            p90_risk: None,
1664            community_count: None,
1665            modularity: None,
1666        };
1667        let mut buf = Vec::new();
1668        stats.fmt_compact(&mut buf).unwrap();
1669        let s = String::from_utf8(buf).unwrap();
1670        assert!(!s.contains("Entry points"));
1671        assert!(!s.contains("Avg criticality"));
1672    }
1673
1674    #[test]
1675    fn graph_stats_zero_symbols_shows_zero_flow_fields() {
1676        let stats = GraphStats {
1677            files: 0,
1678            symbols: 0,
1679            edges: 0,
1680            entry_point_count: Some(0),
1681            avg_criticality: Some(0.0),
1682            clone_clusters: None,
1683            duplication_pct: None,
1684            most_duplicated: None,
1685            avg_risk: None,
1686            p90_risk: None,
1687            community_count: None,
1688            modularity: None,
1689        };
1690        let mut buf = Vec::new();
1691        stats.fmt_compact(&mut buf).unwrap();
1692        let s = String::from_utf8(buf).unwrap();
1693        assert!(s.contains("Entry points: 0"));
1694    }
1695
1696    #[test]
1697    fn graph_stats_compact_with_risk_fields() {
1698        let stats = GraphStats {
1699            files: 234,
1700            symbols: 1892,
1701            edges: 5431,
1702            entry_point_count: Some(12),
1703            avg_criticality: Some(0.034),
1704            clone_clusters: None,
1705            duplication_pct: None,
1706            most_duplicated: None,
1707            avg_risk: Some(0.23),
1708            p90_risk: Some(0.61),
1709            community_count: None,
1710            modularity: None,
1711        };
1712        let mut buf = Vec::new();
1713        stats.fmt_compact(&mut buf).unwrap();
1714        let s = String::from_utf8(buf).unwrap();
1715        assert!(s.contains("Avg risk: 0.23"));
1716        assert!(s.contains("P90 risk: 0.61"));
1717    }
1718
1719    #[test]
1720    fn graph_stats_table_with_risk_fields() {
1721        let stats = GraphStats {
1722            files: 10,
1723            symbols: 50,
1724            edges: 100,
1725            entry_point_count: None,
1726            avg_criticality: None,
1727            clone_clusters: None,
1728            duplication_pct: None,
1729            most_duplicated: None,
1730            avg_risk: Some(0.30),
1731            p90_risk: Some(0.55),
1732            community_count: None,
1733            modularity: None,
1734        };
1735        let mut buf = Vec::new();
1736        stats.fmt_table(&mut buf).unwrap();
1737        let s = String::from_utf8(buf).unwrap();
1738        assert!(s.contains("Avg risk  | 0.30"));
1739        assert!(s.contains("P90 risk  | 0.55"));
1740    }
1741
1742    // -----------------------------------------------------------------------
1743    // RiskAnalysis tests
1744    // -----------------------------------------------------------------------
1745
1746    fn sample_risk_analysis() -> domain::model::RiskAnalysis {
1747        domain::model::RiskAnalysis {
1748            symbol_scores: vec![domain::model::RiskScore {
1749                qualified_name: "src/auth.rs::validate".into(),
1750                composite: 0.78,
1751                factors: domain::model::RiskFactors {
1752                    criticality: 0.72,
1753                    coupling: 0.81,
1754                    test_gap: 1.00,
1755                    sensitivity: 1.00,
1756                },
1757            }],
1758            file_scores: vec![domain::model::FileRiskScore {
1759                path: "src/auth.rs".into(),
1760                composite: 0.78,
1761                symbol_count: 1,
1762                highest_symbol: "validate".into(),
1763            }],
1764            stats: domain::model::RiskStats {
1765                symbols_scored: 1,
1766                files_scored: 1,
1767                avg_risk: 0.78,
1768                median_risk: 0.78,
1769                p90_risk: 0.78,
1770            },
1771        }
1772    }
1773
1774    #[test]
1775    fn risk_analysis_compact_format() {
1776        let analysis = sample_risk_analysis();
1777        let mut buf = Vec::new();
1778        analysis.fmt_compact(&mut buf).unwrap();
1779        let s = String::from_utf8(buf).unwrap();
1780        assert!(s.contains("Files by risk"));
1781        assert!(s.contains("src/auth.rs"));
1782        assert!(s.contains("0.78"));
1783        assert!(s.contains("validate"));
1784    }
1785
1786    #[test]
1787    fn risk_analysis_json_format() {
1788        let analysis = sample_risk_analysis();
1789        let mut buf = Vec::new();
1790        analysis.fmt_json(&mut buf).unwrap();
1791        let s = String::from_utf8(buf).unwrap();
1792        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1793        assert!(parsed["file_scores"].is_array());
1794        assert_eq!(parsed["stats"]["avg_risk"], 0.78);
1795    }
1796
1797    #[test]
1798    fn risk_score_vec_compact_format() {
1799        let scores = vec![domain::model::RiskScore {
1800            qualified_name: "src/auth.rs::validate".into(),
1801            composite: 0.82,
1802            factors: domain::model::RiskFactors {
1803                criticality: 0.72,
1804                coupling: 0.81,
1805                test_gap: 1.0,
1806                sensitivity: 1.0,
1807            },
1808        }];
1809        let mut buf = Vec::new();
1810        scores.fmt_compact(&mut buf).unwrap();
1811        let s = String::from_utf8(buf).unwrap();
1812        assert!(s.contains("Symbol"));
1813        assert!(s.contains("src/auth.rs::validate"));
1814        assert!(s.contains("0.82"));
1815    }
1816
1817    #[test]
1818    fn risk_score_detail_compact_format() {
1819        let detail = RiskScoreDetail {
1820            score: domain::model::RiskScore {
1821                qualified_name: "src/auth.rs::validate".into(),
1822                composite: 0.82,
1823                factors: domain::model::RiskFactors {
1824                    criticality: 0.72,
1825                    coupling: 0.81,
1826                    test_gap: 1.0,
1827                    sensitivity: 1.0,
1828                },
1829            },
1830            matched_patterns: vec!["auth".into()],
1831            weights: domain::model::RiskWeights::default(),
1832        };
1833        let mut buf = Vec::new();
1834        detail.fmt_compact(&mut buf).unwrap();
1835        let s = String::from_utf8(buf).unwrap();
1836        assert!(s.contains("risk = 0.82"));
1837        assert!(s.contains("criticality:  0.72"));
1838        assert!(s.contains("coupling:     0.81"));
1839        assert!(s.contains("test_gap:     1.00"));
1840        assert!(s.contains("sensitivity:  1.00"));
1841        assert!(s.contains("matches: auth"));
1842        assert!(s.contains("weights: crit=0.30 coup=0.25 test=0.25 sec=0.20"));
1843    }
1844}