greppy/trace/output/
plain.rs

1//! Plain text output formatters
2//!
3//! Provides simple text output without ANSI codes for:
4//! - Piping to other tools
5//! - Log files
6//! - Environments without color support
7//!
8//! Also includes CSV, DOT, and Markdown formatters.
9//!
10//! @module trace/output/plain
11
12use super::{
13    DeadCodeResult, FlowResult, ImpactResult, ModuleResult, PatternResult, RefsResult, ScopeResult,
14    StatsResult, TraceFormatter, TraceResult,
15};
16
17// =============================================================================
18// PLAIN TEXT FORMATTER
19// =============================================================================
20
21/// Plain text formatter (no ANSI codes)
22pub struct PlainFormatter;
23
24impl PlainFormatter {
25    /// Create a new plain text formatter
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31impl Default for PlainFormatter {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl TraceFormatter for PlainFormatter {
38    fn format_trace(&self, result: &TraceResult) -> String {
39        let mut output = String::new();
40
41        output.push_str(&format!("TRACE: {}\n", result.symbol));
42        if let Some(ref defined_at) = result.defined_at {
43            output.push_str(&format!("Defined: {}\n", defined_at));
44        }
45        output.push_str(&format!(
46            "Found: {} invocation paths from {} entry points\n",
47            result.total_paths, result.entry_points
48        ));
49        output.push_str(&"-".repeat(60));
50        output.push('\n');
51
52        for (i, path) in result.invocation_paths.iter().enumerate() {
53            output.push_str(&format!(
54                "\nPath {}/{} (entry: {})\n",
55                i + 1,
56                result.total_paths,
57                path.entry_point
58            ));
59
60            for (j, step) in path.chain.iter().enumerate() {
61                let prefix = if j == path.chain.len() - 1 {
62                    "  -> "
63                } else {
64                    "     "
65                };
66                output.push_str(&format!(
67                    "{}{}:{} - {}\n",
68                    prefix, step.file, step.line, step.symbol
69                ));
70
71                // Show context if available
72                if let Some(ref ctx) = step.context {
73                    for line in ctx.lines() {
74                        output.push_str(&format!("       {}\n", line));
75                    }
76                }
77            }
78        }
79
80        output
81    }
82
83    fn format_refs(&self, result: &RefsResult) -> String {
84        let mut output = String::new();
85
86        output.push_str(&format!("REFS: {}\n", result.symbol));
87        if let Some(ref defined_at) = result.defined_at {
88            output.push_str(&format!("Defined: {}\n", defined_at));
89        }
90        output.push_str(&format!("Found: {} references\n", result.total_refs));
91
92        if !result.by_kind.is_empty() {
93            output.push_str("By kind: ");
94            let kinds: Vec<_> = result
95                .by_kind
96                .iter()
97                .map(|(k, v)| format!("{}={}", k, v))
98                .collect();
99            output.push_str(&kinds.join(", "));
100            output.push('\n');
101        }
102        output.push_str(&"-".repeat(60));
103        output.push('\n');
104
105        let mut by_file: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
106        for r in &result.references {
107            by_file.entry(&r.file).or_default().push(r);
108        }
109
110        for (file, refs) in by_file {
111            output.push_str(&format!("\n{}:\n", file));
112            for r in refs {
113                // Handle multi-line context
114                let context_lines: Vec<&str> = r.context.lines().collect();
115                if context_lines.len() > 1 {
116                    output.push_str(&format!("  {}:{} [{}]\n", r.line, r.column, r.kind));
117                    for line in &context_lines {
118                        output.push_str(&format!("    {}\n", line));
119                    }
120                } else {
121                    output.push_str(&format!(
122                        "  {}:{} [{}] {}",
123                        r.line,
124                        r.column,
125                        r.kind,
126                        r.context.trim()
127                    ));
128                    if let Some(ref enclosing) = r.enclosing_symbol {
129                        output.push_str(&format!(" (in {})", enclosing));
130                    }
131                    output.push('\n');
132                }
133            }
134        }
135
136        output
137    }
138
139    fn format_dead_code(&self, result: &DeadCodeResult) -> String {
140        let mut output = String::new();
141
142        output.push_str("DEAD CODE ANALYSIS\n");
143        output.push_str(&format!("Found: {} unused symbols\n", result.total_dead));
144
145        if !result.by_kind.is_empty() {
146            output.push_str("By kind: ");
147            let kinds: Vec<_> = result
148                .by_kind
149                .iter()
150                .map(|(k, v)| format!("{}={}", k, v))
151                .collect();
152            output.push_str(&kinds.join(", "));
153            output.push('\n');
154        }
155        output.push_str(&"-".repeat(60));
156        output.push('\n');
157
158        for sym in &result.symbols {
159            output.push_str(&format!(
160                "{}  {}:{}  {} - {}\n",
161                sym.kind, sym.file, sym.line, sym.name, sym.reason
162            ));
163        }
164
165        output
166    }
167
168    fn format_flow(&self, result: &FlowResult) -> String {
169        let mut output = String::new();
170
171        output.push_str(&format!("DATA FLOW: {}\n", result.symbol));
172        output.push_str(&format!("Paths: {}\n", result.flow_paths.len()));
173        output.push_str(&"-".repeat(60));
174        output.push('\n');
175
176        for (i, path) in result.flow_paths.iter().enumerate() {
177            output.push_str(&format!("\nFlow Path {}:\n", i + 1));
178
179            for step in path {
180                output.push_str(&format!(
181                    "  {}:{} [{}] {}\n",
182                    step.file,
183                    step.line,
184                    step.action,
185                    step.expression.trim()
186                ));
187            }
188        }
189
190        output
191    }
192
193    fn format_impact(&self, result: &ImpactResult) -> String {
194        let mut output = String::new();
195
196        output.push_str(&format!("IMPACT ANALYSIS: {}\n", result.symbol));
197        output.push_str(&format!("File: {}\n", result.file));
198        output.push_str(&format!("Risk Level: {}\n", result.risk_level));
199        output.push_str(&"-".repeat(60));
200        output.push('\n');
201
202        output.push_str(&format!(
203            "\nDirect callers ({}):\n",
204            result.direct_caller_count
205        ));
206        for caller in &result.direct_callers {
207            output.push_str(&format!("  {}\n", caller));
208        }
209
210        if !result.transitive_callers.is_empty() {
211            output.push_str(&format!(
212                "\nTransitive callers ({}):\n",
213                result.transitive_caller_count
214            ));
215            for caller in &result.transitive_callers {
216                output.push_str(&format!("  {}\n", caller));
217            }
218        }
219
220        output.push_str(&format!(
221            "\nAffected entry points ({}):\n",
222            result.affected_entry_points.len()
223        ));
224        for ep in &result.affected_entry_points {
225            output.push_str(&format!("  {}\n", ep));
226        }
227
228        output.push_str(&format!(
229            "\nFiles affected: {}\n",
230            result.files_affected.len()
231        ));
232
233        output
234    }
235
236    fn format_module(&self, result: &ModuleResult) -> String {
237        let mut output = String::new();
238
239        output.push_str(&format!("MODULE: {}\n", result.module));
240        output.push_str(&format!("Path: {}\n", result.file_path));
241        output.push_str(&"-".repeat(60));
242        output.push('\n');
243
244        if !result.exports.is_empty() {
245            output.push_str(&format!("\nExports ({}):\n", result.exports.len()));
246            for export in &result.exports {
247                output.push_str(&format!("  {}\n", export));
248            }
249        }
250
251        if !result.imported_by.is_empty() {
252            output.push_str(&format!("\nImported by ({}):\n", result.imported_by.len()));
253            for importer in &result.imported_by {
254                output.push_str(&format!("  {}\n", importer));
255            }
256        }
257
258        if !result.dependencies.is_empty() {
259            output.push_str(&format!(
260                "\nDependencies ({}):\n",
261                result.dependencies.len()
262            ));
263            for dep in &result.dependencies {
264                output.push_str(&format!("  {}\n", dep));
265            }
266        }
267
268        if !result.circular_deps.is_empty() {
269            output.push_str(&format!(
270                "\nCIRCULAR DEPENDENCIES ({}):\n",
271                result.circular_deps.len()
272            ));
273            for cycle in &result.circular_deps {
274                output.push_str(&format!("  WARNING: {}\n", cycle));
275            }
276        }
277
278        output
279    }
280
281    fn format_pattern(&self, result: &PatternResult) -> String {
282        let mut output = String::new();
283
284        output.push_str(&format!("PATTERN: {}\n", result.pattern));
285        output.push_str(&format!(
286            "Found: {} matches in {} files\n",
287            result.total_matches,
288            result.by_file.len()
289        ));
290        output.push_str(&"-".repeat(60));
291        output.push('\n');
292
293        let mut by_file: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
294        for m in &result.matches {
295            by_file.entry(&m.file).or_default().push(m);
296        }
297
298        for (file, matches) in by_file {
299            output.push_str(&format!("\n{}:\n", file));
300            for m in matches {
301                // Handle multi-line context
302                let context_lines: Vec<&str> = m.context.lines().collect();
303                if context_lines.len() > 1 {
304                    output.push_str(&format!("  {}:{}:\n", m.line, m.column));
305                    for line in &context_lines {
306                        output.push_str(&format!("    {}\n", line));
307                    }
308                } else {
309                    output.push_str(&format!(
310                        "  {}:{}: {}\n",
311                        m.line,
312                        m.column,
313                        m.context.trim()
314                    ));
315                }
316            }
317        }
318
319        output
320    }
321
322    fn format_scope(&self, result: &ScopeResult) -> String {
323        let mut output = String::new();
324
325        output.push_str(&format!("SCOPE AT: {}:{}\n", result.file, result.line));
326        if let Some(ref scope) = result.enclosing_scope {
327            output.push_str(&format!("Enclosing: {}\n", scope));
328        }
329        output.push_str(&"-".repeat(60));
330        output.push('\n');
331
332        if !result.local_variables.is_empty() {
333            output.push_str(&format!(
334                "\nLocal Variables ({}):\n",
335                result.local_variables.len()
336            ));
337            for var in &result.local_variables {
338                output.push_str(&format!(
339                    "  {}: {} (line {})\n",
340                    var.name, var.kind, var.defined_at
341                ));
342            }
343        }
344
345        if !result.parameters.is_empty() {
346            output.push_str(&format!("\nParameters ({}):\n", result.parameters.len()));
347            for param in &result.parameters {
348                output.push_str(&format!("  {}: {}\n", param.name, param.kind));
349            }
350        }
351
352        if !result.imports.is_empty() {
353            output.push_str(&format!("\nImports ({}):\n", result.imports.len()));
354            for import in &result.imports {
355                output.push_str(&format!("  {}\n", import));
356            }
357        }
358
359        output
360    }
361
362    fn format_stats(&self, result: &StatsResult) -> String {
363        let mut output = String::new();
364
365        output.push_str("CODEBASE STATISTICS\n");
366        output.push_str(&"-".repeat(60));
367        output.push('\n');
368
369        output.push_str(&format!("\nFiles:        {}\n", result.total_files));
370        output.push_str(&format!("Symbols:      {}\n", result.total_symbols));
371        output.push_str(&format!("Tokens:       {}\n", result.total_tokens));
372        output.push_str(&format!("References:   {}\n", result.total_references));
373        output.push_str(&format!("Call Edges:   {}\n", result.total_edges));
374        output.push_str(&format!("Entry Points: {}\n", result.total_entry_points));
375
376        output.push_str("\nSymbols by kind:\n");
377        let mut kinds: Vec<_> = result.symbols_by_kind.iter().collect();
378        kinds.sort_by(|a, b| b.1.cmp(a.1));
379        for (kind, count) in &kinds {
380            output.push_str(&format!("  {}: {}\n", kind, count));
381        }
382
383        output.push_str("\nCall Graph:\n");
384        output.push_str(&format!("  Max Depth: {}\n", result.max_call_depth));
385        output.push_str(&format!("  Avg Depth: {:.1}\n", result.avg_call_depth));
386
387        output
388    }
389}
390
391// =============================================================================
392// CSV FORMATTER
393// =============================================================================
394
395/// CSV formatter for spreadsheet export
396pub struct CsvFormatter;
397
398impl CsvFormatter {
399    pub fn new() -> Self {
400        Self
401    }
402
403    fn escape_csv(s: &str) -> String {
404        if s.contains(',') || s.contains('"') || s.contains('\n') {
405            format!("\"{}\"", s.replace('"', "\"\""))
406        } else {
407            s.to_string()
408        }
409    }
410}
411
412impl Default for CsvFormatter {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418impl TraceFormatter for CsvFormatter {
419    fn format_trace(&self, result: &TraceResult) -> String {
420        let mut output = String::from("path_num,entry_point,entry_kind,step,symbol,file,line\n");
421
422        for (i, path) in result.invocation_paths.iter().enumerate() {
423            for (j, step) in path.chain.iter().enumerate() {
424                output.push_str(&format!(
425                    "{},{},{},{},{},{},{}\n",
426                    i + 1,
427                    Self::escape_csv(&path.entry_point),
428                    Self::escape_csv(&path.entry_kind),
429                    j + 1,
430                    Self::escape_csv(&step.symbol),
431                    Self::escape_csv(&step.file),
432                    step.line
433                ));
434            }
435        }
436
437        output
438    }
439
440    fn format_refs(&self, result: &RefsResult) -> String {
441        let mut output = String::from("file,line,column,kind,context,enclosing_symbol\n");
442
443        for r in &result.references {
444            let context_single = r.context.lines().next().unwrap_or("").trim();
445            output.push_str(&format!(
446                "{},{},{},{},{},{}\n",
447                Self::escape_csv(&r.file),
448                r.line,
449                r.column,
450                r.kind,
451                Self::escape_csv(context_single),
452                Self::escape_csv(r.enclosing_symbol.as_deref().unwrap_or(""))
453            ));
454        }
455
456        output
457    }
458
459    fn format_dead_code(&self, result: &DeadCodeResult) -> String {
460        let mut output = String::from("name,kind,file,line,reason\n");
461
462        for sym in &result.symbols {
463            output.push_str(&format!(
464                "{},{},{},{},{}\n",
465                Self::escape_csv(&sym.name),
466                Self::escape_csv(&sym.kind),
467                Self::escape_csv(&sym.file),
468                sym.line,
469                Self::escape_csv(&sym.reason)
470            ));
471        }
472
473        output
474    }
475
476    fn format_flow(&self, result: &FlowResult) -> String {
477        let mut output = String::from("path,step,variable,action,file,line,expression\n");
478
479        for (i, path) in result.flow_paths.iter().enumerate() {
480            for (j, step) in path.iter().enumerate() {
481                output.push_str(&format!(
482                    "{},{},{},{},{},{},{}\n",
483                    i + 1,
484                    j + 1,
485                    Self::escape_csv(&step.variable),
486                    step.action,
487                    Self::escape_csv(&step.file),
488                    step.line,
489                    Self::escape_csv(step.expression.trim())
490                ));
491            }
492        }
493
494        output
495    }
496
497    fn format_impact(&self, result: &ImpactResult) -> String {
498        let mut output = String::from("type,value\n");
499
500        output.push_str(&format!("symbol,{}\n", Self::escape_csv(&result.symbol)));
501        output.push_str(&format!("file,{}\n", Self::escape_csv(&result.file)));
502        output.push_str(&format!("risk_level,{}\n", result.risk_level));
503        output.push_str(&format!(
504            "direct_caller_count,{}\n",
505            result.direct_caller_count
506        ));
507        output.push_str(&format!(
508            "transitive_caller_count,{}\n",
509            result.transitive_caller_count
510        ));
511        output.push_str(&format!(
512            "affected_entry_points,{}\n",
513            result.affected_entry_points.len()
514        ));
515        output.push_str(&format!("files_affected,{}\n", result.files_affected.len()));
516
517        output
518    }
519
520    fn format_module(&self, result: &ModuleResult) -> String {
521        let mut output = String::from("type,value\n");
522
523        output.push_str(&format!("module,{}\n", Self::escape_csv(&result.module)));
524        output.push_str(&format!("path,{}\n", Self::escape_csv(&result.file_path)));
525        output.push_str(&format!("exports,{}\n", result.exports.len()));
526        output.push_str(&format!("imported_by,{}\n", result.imported_by.len()));
527        output.push_str(&format!("dependencies,{}\n", result.dependencies.len()));
528        output.push_str(&format!("circular_deps,{}\n", result.circular_deps.len()));
529
530        output
531    }
532
533    fn format_pattern(&self, result: &PatternResult) -> String {
534        let mut output = String::from("file,line,column,matched_text,context\n");
535
536        for m in &result.matches {
537            let context_single = m.context.lines().next().unwrap_or("").trim();
538            output.push_str(&format!(
539                "{},{},{},{},{}\n",
540                Self::escape_csv(&m.file),
541                m.line,
542                m.column,
543                Self::escape_csv(&m.matched_text),
544                Self::escape_csv(context_single)
545            ));
546        }
547
548        output
549    }
550
551    fn format_scope(&self, result: &ScopeResult) -> String {
552        let mut output = String::from("type,name,kind,defined_at\n");
553
554        for var in &result.local_variables {
555            output.push_str(&format!(
556                "local,{},{},{}\n",
557                Self::escape_csv(&var.name),
558                Self::escape_csv(&var.kind),
559                var.defined_at
560            ));
561        }
562
563        for param in &result.parameters {
564            output.push_str(&format!(
565                "param,{},{},\n",
566                Self::escape_csv(&param.name),
567                Self::escape_csv(&param.kind)
568            ));
569        }
570
571        for import in &result.imports {
572            output.push_str(&format!("import,{},module,\n", Self::escape_csv(import)));
573        }
574
575        output
576    }
577
578    fn format_stats(&self, result: &StatsResult) -> String {
579        let mut output = String::from("metric,value\n");
580
581        output.push_str(&format!("total_files,{}\n", result.total_files));
582        output.push_str(&format!("total_symbols,{}\n", result.total_symbols));
583        output.push_str(&format!("total_tokens,{}\n", result.total_tokens));
584        output.push_str(&format!("total_references,{}\n", result.total_references));
585        output.push_str(&format!("total_edges,{}\n", result.total_edges));
586        output.push_str(&format!(
587            "total_entry_points,{}\n",
588            result.total_entry_points
589        ));
590        output.push_str(&format!("max_call_depth,{}\n", result.max_call_depth));
591        output.push_str(&format!("avg_call_depth,{:.2}\n", result.avg_call_depth));
592
593        output
594    }
595}
596
597// =============================================================================
598// DOT FORMATTER (Graph Visualization)
599// =============================================================================
600
601/// DOT formatter for graph visualization
602pub struct DotFormatter;
603
604impl DotFormatter {
605    pub fn new() -> Self {
606        Self
607    }
608
609    fn escape_dot(s: &str) -> String {
610        s.replace('"', "\\\"").replace('\n', "\\n")
611    }
612}
613
614impl Default for DotFormatter {
615    fn default() -> Self {
616        Self::new()
617    }
618}
619
620impl TraceFormatter for DotFormatter {
621    fn format_trace(&self, result: &TraceResult) -> String {
622        let mut output = String::from("digraph trace {\n");
623        output.push_str("  rankdir=LR;\n");
624        output.push_str("  node [shape=box];\n\n");
625
626        let mut nodes = std::collections::HashSet::new();
627        let mut edges = Vec::new();
628
629        for path in &result.invocation_paths {
630            for (i, step) in path.chain.iter().enumerate() {
631                let node_id = format!("{}_{}", step.symbol.replace(['.', '/'], "_"), step.line);
632                if !nodes.contains(&node_id) {
633                    nodes.insert(node_id.clone());
634                    output.push_str(&format!(
635                        "  {} [label=\"{}\\n{}:{}\"];\n",
636                        node_id,
637                        Self::escape_dot(&step.symbol),
638                        Self::escape_dot(&step.file),
639                        step.line
640                    ));
641                }
642
643                if i > 0 {
644                    let prev = &path.chain[i - 1];
645                    let prev_id = format!("{}_{}", prev.symbol.replace(['.', '/'], "_"), prev.line);
646                    let edge = (prev_id.clone(), node_id.clone());
647                    if !edges.contains(&edge) {
648                        edges.push(edge.clone());
649                        output.push_str(&format!("  {} -> {};\n", edge.0, edge.1));
650                    }
651                }
652            }
653        }
654
655        output.push_str("}\n");
656        output
657    }
658
659    fn format_refs(&self, result: &RefsResult) -> String {
660        let mut output = String::from("digraph refs {\n");
661        output.push_str("  rankdir=TB;\n");
662        output.push_str(&format!(
663            "  center [label=\"{}\" shape=ellipse style=filled fillcolor=yellow];\n",
664            Self::escape_dot(&result.symbol)
665        ));
666
667        for (i, r) in result.references.iter().enumerate() {
668            let node_id = format!("ref_{}", i);
669            let label = format!("{}:{}", r.file, r.line);
670            output.push_str(&format!(
671                "  {} [label=\"{}\" shape=box];\n",
672                node_id,
673                Self::escape_dot(&label)
674            ));
675            output.push_str(&format!(
676                "  center -> {} [label=\"{}\"];\n",
677                node_id, r.kind
678            ));
679        }
680
681        output.push_str("}\n");
682        output
683    }
684
685    fn format_dead_code(&self, result: &DeadCodeResult) -> String {
686        let mut output = String::from("digraph dead_code {\n");
687        output.push_str("  node [shape=box style=filled fillcolor=lightgray];\n");
688
689        for (i, sym) in result.symbols.iter().enumerate() {
690            output.push_str(&format!(
691                "  dead_{} [label=\"{}\\n{}:{}\"];\n",
692                i,
693                Self::escape_dot(&sym.name),
694                Self::escape_dot(&sym.file),
695                sym.line
696            ));
697        }
698
699        output.push_str("}\n");
700        output
701    }
702
703    fn format_flow(&self, result: &FlowResult) -> String {
704        let mut output = String::from("digraph flow {\n");
705        output.push_str("  rankdir=TB;\n");
706
707        for (path_idx, path) in result.flow_paths.iter().enumerate() {
708            for (i, step) in path.iter().enumerate() {
709                let node_id = format!("p{}_s{}", path_idx, i);
710                output.push_str(&format!(
711                    "  {} [label=\"[{}] {}\\n{}:{}\"];\n",
712                    node_id,
713                    step.action,
714                    Self::escape_dot(&step.variable),
715                    Self::escape_dot(&step.file),
716                    step.line
717                ));
718
719                if i > 0 {
720                    let prev_id = format!("p{}_s{}", path_idx, i - 1);
721                    output.push_str(&format!("  {} -> {};\n", prev_id, node_id));
722                }
723            }
724        }
725
726        output.push_str("}\n");
727        output
728    }
729
730    fn format_impact(&self, result: &ImpactResult) -> String {
731        let mut output = String::from("digraph impact {\n");
732        output.push_str("  rankdir=BT;\n");
733        output.push_str(&format!(
734            "  target [label=\"{}\" shape=ellipse style=filled fillcolor=red fontcolor=white];\n",
735            Self::escape_dot(&result.symbol)
736        ));
737
738        for (i, caller) in result.direct_callers.iter().enumerate() {
739            output.push_str(&format!(
740                "  direct_{} [label=\"{}\"];\n",
741                i,
742                Self::escape_dot(caller)
743            ));
744            output.push_str(&format!("  direct_{} -> target [color=red];\n", i));
745        }
746
747        output.push_str("}\n");
748        output
749    }
750
751    fn format_module(&self, result: &ModuleResult) -> String {
752        let mut output = String::from("digraph module {\n");
753        output.push_str("  rankdir=LR;\n");
754        output.push_str(&format!(
755            "  module [label=\"{}\" shape=box style=filled fillcolor=lightblue];\n",
756            Self::escape_dot(&result.module)
757        ));
758
759        for (i, dep) in result.dependencies.iter().enumerate() {
760            output.push_str(&format!(
761                "  dep_{} [label=\"{}\"];\n",
762                i,
763                Self::escape_dot(dep)
764            ));
765            output.push_str(&format!("  module -> dep_{};\n", i));
766        }
767
768        for (i, importer) in result.imported_by.iter().enumerate() {
769            output.push_str(&format!(
770                "  importer_{} [label=\"{}\"];\n",
771                i,
772                Self::escape_dot(importer)
773            ));
774            output.push_str(&format!("  importer_{} -> module;\n", i));
775        }
776
777        output.push_str("}\n");
778        output
779    }
780
781    fn format_pattern(&self, _result: &PatternResult) -> String {
782        String::from("// Pattern results not suitable for DOT format\n")
783    }
784
785    fn format_scope(&self, _result: &ScopeResult) -> String {
786        String::from("// Scope results not suitable for DOT format\n")
787    }
788
789    fn format_stats(&self, _result: &StatsResult) -> String {
790        String::from("// Stats not suitable for DOT format\n")
791    }
792}
793
794// =============================================================================
795// MARKDOWN FORMATTER
796// =============================================================================
797
798/// Markdown formatter for documentation
799pub struct MarkdownFormatter;
800
801impl MarkdownFormatter {
802    pub fn new() -> Self {
803        Self
804    }
805}
806
807impl Default for MarkdownFormatter {
808    fn default() -> Self {
809        Self::new()
810    }
811}
812
813impl TraceFormatter for MarkdownFormatter {
814    fn format_trace(&self, result: &TraceResult) -> String {
815        let mut output = String::new();
816
817        output.push_str(&format!("# Trace: {}\n\n", result.symbol));
818
819        if let Some(ref defined_at) = result.defined_at {
820            output.push_str(&format!("**Defined at:** `{}`\n\n", defined_at));
821        }
822
823        output.push_str(&format!(
824            "**Found:** {} invocation paths from {} entry points\n\n",
825            result.total_paths, result.entry_points
826        ));
827
828        for (i, path) in result.invocation_paths.iter().enumerate() {
829            output.push_str(&format!("## Path {}/{}\n\n", i + 1, result.total_paths));
830            output.push_str(&format!(
831                "**Entry:** {} ({})\n\n",
832                path.entry_point, path.entry_kind
833            ));
834
835            output.push_str("| Step | Symbol | Location |\n");
836            output.push_str("|------|--------|----------|\n");
837
838            for (j, step) in path.chain.iter().enumerate() {
839                output.push_str(&format!(
840                    "| {} | `{}` | `{}:{}` |\n",
841                    j + 1,
842                    step.symbol,
843                    step.file,
844                    step.line
845                ));
846            }
847            output.push('\n');
848        }
849
850        output
851    }
852
853    fn format_refs(&self, result: &RefsResult) -> String {
854        let mut output = String::new();
855
856        output.push_str(&format!("# References: {}\n\n", result.symbol));
857
858        if let Some(ref defined_at) = result.defined_at {
859            output.push_str(&format!("**Defined at:** `{}`\n\n", defined_at));
860        }
861
862        output.push_str(&format!("**Total:** {} references\n\n", result.total_refs));
863
864        if !result.by_kind.is_empty() {
865            output.push_str("### By Kind\n\n");
866            for (kind, count) in &result.by_kind {
867                output.push_str(&format!("- **{}:** {}\n", kind, count));
868            }
869            output.push('\n');
870        }
871
872        output.push_str("### References\n\n");
873        output.push_str("| File | Line | Kind | Context |\n");
874        output.push_str("|------|------|------|----------|\n");
875
876        for r in &result.references {
877            let context_short = r.context.lines().next().unwrap_or("").trim();
878            let context_escaped = context_short.replace('|', "\\|");
879            output.push_str(&format!(
880                "| `{}` | {} | {} | `{}` |\n",
881                r.file, r.line, r.kind, context_escaped
882            ));
883        }
884
885        output
886    }
887
888    fn format_dead_code(&self, result: &DeadCodeResult) -> String {
889        let mut output = String::new();
890
891        output.push_str("# Dead Code Analysis\n\n");
892        output.push_str(&format!(
893            "**Found:** {} unused symbols\n\n",
894            result.total_dead
895        ));
896
897        if !result.by_kind.is_empty() {
898            output.push_str("### By Kind\n\n");
899            for (kind, count) in &result.by_kind {
900                output.push_str(&format!("- **{}:** {}\n", kind, count));
901            }
902            output.push('\n');
903        }
904
905        output.push_str("### Unused Symbols\n\n");
906        output.push_str("| Name | Kind | File | Line |\n");
907        output.push_str("|------|------|------|------|\n");
908
909        for sym in &result.symbols {
910            output.push_str(&format!(
911                "| `{}` | {} | `{}` | {} |\n",
912                sym.name, sym.kind, sym.file, sym.line
913            ));
914        }
915
916        output
917    }
918
919    fn format_flow(&self, result: &FlowResult) -> String {
920        let mut output = String::new();
921
922        output.push_str(&format!("# Data Flow: {}\n\n", result.symbol));
923
924        for (i, path) in result.flow_paths.iter().enumerate() {
925            output.push_str(&format!("## Flow Path {}\n\n", i + 1));
926
927            for step in path {
928                output.push_str(&format!(
929                    "1. **[{}]** `{}:{}` - `{}`\n",
930                    step.action,
931                    step.file,
932                    step.line,
933                    step.expression.trim()
934                ));
935            }
936            output.push('\n');
937        }
938
939        output
940    }
941
942    fn format_impact(&self, result: &ImpactResult) -> String {
943        let mut output = String::new();
944
945        output.push_str(&format!("# Impact Analysis: {}\n\n", result.symbol));
946        output.push_str(&format!("**File:** `{}`\n\n", result.file));
947        output.push_str(&format!("**Risk Level:** {}\n\n", result.risk_level));
948
949        output.push_str(&format!(
950            "## Direct Callers ({})\n\n",
951            result.direct_caller_count
952        ));
953        for caller in &result.direct_callers {
954            output.push_str(&format!("- `{}`\n", caller));
955        }
956        output.push('\n');
957
958        if !result.transitive_callers.is_empty() {
959            output.push_str(&format!(
960                "## Transitive Callers ({})\n\n",
961                result.transitive_caller_count
962            ));
963            for caller in result.transitive_callers.iter().take(20) {
964                output.push_str(&format!("- `{}`\n", caller));
965            }
966            output.push('\n');
967        }
968
969        output.push_str(&format!(
970            "## Affected Entry Points ({})\n\n",
971            result.affected_entry_points.len()
972        ));
973        for ep in &result.affected_entry_points {
974            output.push_str(&format!("- `{}`\n", ep));
975        }
976
977        output
978    }
979
980    fn format_module(&self, result: &ModuleResult) -> String {
981        let mut output = String::new();
982
983        output.push_str(&format!("# Module: {}\n\n", result.module));
984        output.push_str(&format!("**Path:** `{}`\n\n", result.file_path));
985
986        if !result.exports.is_empty() {
987            output.push_str(&format!("## Exports ({})\n\n", result.exports.len()));
988            for export in &result.exports {
989                output.push_str(&format!("- `{}`\n", export));
990            }
991            output.push('\n');
992        }
993
994        if !result.imported_by.is_empty() {
995            output.push_str(&format!(
996                "## Imported By ({})\n\n",
997                result.imported_by.len()
998            ));
999            for importer in &result.imported_by {
1000                output.push_str(&format!("- `{}`\n", importer));
1001            }
1002            output.push('\n');
1003        }
1004
1005        if !result.dependencies.is_empty() {
1006            output.push_str(&format!(
1007                "## Dependencies ({})\n\n",
1008                result.dependencies.len()
1009            ));
1010            for dep in &result.dependencies {
1011                output.push_str(&format!("- `{}`\n", dep));
1012            }
1013            output.push('\n');
1014        }
1015
1016        if !result.circular_deps.is_empty() {
1017            output.push_str(&format!(
1018                "## ⚠️ Circular Dependencies ({})\n\n",
1019                result.circular_deps.len()
1020            ));
1021            for cycle in &result.circular_deps {
1022                output.push_str(&format!("- `{}`\n", cycle));
1023            }
1024        }
1025
1026        output
1027    }
1028
1029    fn format_pattern(&self, result: &PatternResult) -> String {
1030        let mut output = String::new();
1031
1032        output.push_str(&format!("# Pattern: `{}`\n\n", result.pattern));
1033        output.push_str(&format!(
1034            "**Found:** {} matches in {} files\n\n",
1035            result.total_matches,
1036            result.by_file.len()
1037        ));
1038
1039        output.push_str("## Matches\n\n");
1040        output.push_str("| File | Line | Match |\n");
1041        output.push_str("|------|------|-------|\n");
1042
1043        for m in &result.matches {
1044            let match_escaped = m.matched_text.replace('|', "\\|");
1045            output.push_str(&format!(
1046                "| `{}` | {} | `{}` |\n",
1047                m.file, m.line, match_escaped
1048            ));
1049        }
1050
1051        output
1052    }
1053
1054    fn format_scope(&self, result: &ScopeResult) -> String {
1055        let mut output = String::new();
1056
1057        output.push_str(&format!("# Scope at `{}:{}`\n\n", result.file, result.line));
1058
1059        if let Some(ref scope) = result.enclosing_scope {
1060            output.push_str(&format!("**Enclosing:** `{}`\n\n", scope));
1061        }
1062
1063        if !result.local_variables.is_empty() {
1064            output.push_str(&format!(
1065                "## Local Variables ({})\n\n",
1066                result.local_variables.len()
1067            ));
1068            output.push_str("| Name | Type | Defined At |\n");
1069            output.push_str("|------|------|------------|\n");
1070            for var in &result.local_variables {
1071                output.push_str(&format!(
1072                    "| `{}` | {} | line {} |\n",
1073                    var.name, var.kind, var.defined_at
1074                ));
1075            }
1076            output.push('\n');
1077        }
1078
1079        if !result.imports.is_empty() {
1080            output.push_str(&format!("## Imports ({})\n\n", result.imports.len()));
1081            for import in &result.imports {
1082                output.push_str(&format!("- `{}`\n", import));
1083            }
1084        }
1085
1086        output
1087    }
1088
1089    fn format_stats(&self, result: &StatsResult) -> String {
1090        let mut output = String::new();
1091
1092        output.push_str("# Codebase Statistics\n\n");
1093
1094        output.push_str("## Overview\n\n");
1095        output.push_str("| Metric | Value |\n");
1096        output.push_str("|--------|-------|\n");
1097        output.push_str(&format!("| Files | {} |\n", result.total_files));
1098        output.push_str(&format!("| Symbols | {} |\n", result.total_symbols));
1099        output.push_str(&format!("| Tokens | {} |\n", result.total_tokens));
1100        output.push_str(&format!("| References | {} |\n", result.total_references));
1101        output.push_str(&format!("| Call Edges | {} |\n", result.total_edges));
1102        output.push_str(&format!(
1103            "| Entry Points | {} |\n",
1104            result.total_entry_points
1105        ));
1106        output.push('\n');
1107
1108        output.push_str("## Symbols by Kind\n\n");
1109        output.push_str("| Kind | Count |\n");
1110        output.push_str("|------|-------|\n");
1111        let mut kinds: Vec<_> = result.symbols_by_kind.iter().collect();
1112        kinds.sort_by(|a, b| b.1.cmp(a.1));
1113        for (kind, count) in &kinds {
1114            output.push_str(&format!("| {} | {} |\n", kind, count));
1115        }
1116        output.push('\n');
1117
1118        output.push_str("## Call Graph\n\n");
1119        output.push_str(&format!("- **Max Depth:** {}\n", result.max_call_depth));
1120        output.push_str(&format!("- **Avg Depth:** {:.1}\n", result.avg_call_depth));
1121
1122        output
1123    }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use crate::trace::output::{ChainStep, InvocationPath};
1130
1131    #[test]
1132    fn test_format_trace_plain() {
1133        let formatter = PlainFormatter::new();
1134        let result = TraceResult {
1135            symbol: "validateUser".to_string(),
1136            defined_at: Some("utils/validation.ts:8".to_string()),
1137            kind: "function".to_string(),
1138            invocation_paths: vec![InvocationPath {
1139                entry_point: "POST /api/auth/login".to_string(),
1140                entry_kind: "route".to_string(),
1141                chain: vec![
1142                    ChainStep {
1143                        symbol: "loginController.handle".to_string(),
1144                        file: "auth.controller.ts".to_string(),
1145                        line: 8,
1146                        column: Some(5),
1147                        context: None,
1148                    },
1149                    ChainStep {
1150                        symbol: "validateUser".to_string(),
1151                        file: "validation.ts".to_string(),
1152                        line: 8,
1153                        column: Some(10),
1154                        context: None,
1155                    },
1156                ],
1157            }],
1158            total_paths: 1,
1159            entry_points: 1,
1160        };
1161
1162        let output = formatter.format_trace(&result);
1163        assert!(output.contains("TRACE: validateUser"));
1164        assert!(output.contains("Defined: utils/validation.ts:8"));
1165        assert!(output.contains("POST /api/auth/login"));
1166        assert!(output.contains("auth.controller.ts:8"));
1167        assert!(!output.contains("\x1b["));
1168    }
1169
1170    #[test]
1171    fn test_format_refs_plain() {
1172        let formatter = PlainFormatter::new();
1173        let result = RefsResult {
1174            symbol: "userId".to_string(),
1175            defined_at: Some("types.ts:5".to_string()),
1176            symbol_kind: None,
1177            references: vec![],
1178            total_refs: 0,
1179            by_kind: std::collections::HashMap::new(),
1180            by_file: std::collections::HashMap::new(),
1181        };
1182
1183        let output = formatter.format_refs(&result);
1184        assert!(output.contains("REFS: userId"));
1185        assert!(!output.contains("\x1b["));
1186    }
1187}