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
10pub 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
88impl 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
146impl 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
173impl 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
214impl 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
248impl 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
319fn 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
392impl 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
437fn 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
466fn 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
507impl 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
564impl 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
626impl 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 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
693impl 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
741impl 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
792impl 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
878impl 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
937impl 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
995impl 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 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 #[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 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 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 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 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 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 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 #[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 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}