greppy/trace/output/
ascii.rs

1//! ASCII output formatter with colors and box-drawing
2//!
3//! Provides rich terminal output with:
4//! - Unicode box-drawing characters
5//! - ANSI color codes
6//! - Terminal width detection
7//!
8//! @module trace/output/ascii
9
10use super::{
11    DeadCodeResult, FlowResult, ImpactResult, ModuleResult, PatternResult, ReferenceKind,
12    RefsResult, RiskLevel, ScopeResult, StatsResult, TraceFormatter, TraceResult,
13};
14
15// =============================================================================
16// CONSTANTS
17// =============================================================================
18
19/// ANSI color codes
20#[allow(dead_code)]
21mod colors {
22    pub const RESET: &str = "\x1b[0m";
23    pub const BOLD: &str = "\x1b[1m";
24    pub const DIM: &str = "\x1b[2m";
25
26    pub const GREEN: &str = "\x1b[32m";
27    pub const YELLOW: &str = "\x1b[33m";
28    pub const BLUE: &str = "\x1b[34m";
29    pub const MAGENTA: &str = "\x1b[35m";
30    pub const CYAN: &str = "\x1b[36m";
31    pub const WHITE: &str = "\x1b[37m";
32    pub const RED: &str = "\x1b[31m";
33
34    pub const BG_RED: &str = "\x1b[41m";
35    pub const BG_YELLOW: &str = "\x1b[43m";
36}
37
38/// Box-drawing characters
39mod box_chars {
40    pub const TOP_LEFT: char = '╔';
41    pub const TOP_RIGHT: char = '╗';
42    pub const BOTTOM_LEFT: char = '╚';
43    pub const BOTTOM_RIGHT: char = '╝';
44    pub const HORIZONTAL: char = '═';
45    pub const VERTICAL: char = '║';
46    pub const THIN_HORIZONTAL: char = '━';
47    pub const ARROW_DOWN: &str = "│";
48    pub const ARROW_RIGHT: &str = "→";
49    pub const TARGET: &str = "←";
50}
51
52// =============================================================================
53// FORMATTER IMPLEMENTATION
54// =============================================================================
55
56/// ASCII formatter with rich terminal output
57pub struct AsciiFormatter {
58    width: usize,
59}
60
61impl AsciiFormatter {
62    /// Create a new ASCII formatter
63    pub fn new() -> Self {
64        Self {
65            width: Self::detect_terminal_width(),
66        }
67    }
68
69    /// Detect terminal width, defaulting to 80
70    fn detect_terminal_width() -> usize {
71        if let Ok(cols) = std::env::var("COLUMNS") {
72            if let Ok(width) = cols.parse::<usize>() {
73                return width.min(200).max(60);
74            }
75        }
76
77        if let Ok(cols) = std::env::var("TERM_WIDTH") {
78            if let Ok(width) = cols.parse::<usize>() {
79                return width.min(200).max(60);
80            }
81        }
82
83        80
84    }
85
86    /// Draw a header box
87    fn draw_header_box<S: AsRef<str>>(&self, lines: &[S]) -> String {
88        let inner_width = self.width - 4;
89        let mut output = String::new();
90
91        output.push(box_chars::TOP_LEFT);
92        for _ in 0..inner_width + 2 {
93            output.push(box_chars::HORIZONTAL);
94        }
95        output.push(box_chars::TOP_RIGHT);
96        output.push('\n');
97
98        for line in lines {
99            output.push(box_chars::VERTICAL);
100            output.push_str("  ");
101            let display_line = self.truncate_or_pad(line.as_ref(), inner_width);
102            output.push_str(&display_line);
103            output.push_str("  ");
104            output.push(box_chars::VERTICAL);
105            output.push('\n');
106        }
107
108        output.push(box_chars::BOTTOM_LEFT);
109        for _ in 0..inner_width + 2 {
110            output.push(box_chars::HORIZONTAL);
111        }
112        output.push(box_chars::BOTTOM_RIGHT);
113        output.push('\n');
114
115        output
116    }
117
118    /// Draw a separator line
119    fn draw_separator(&self, left_text: &str, right_text: &str) -> String {
120        let inner_width = self.width - 2;
121        let left_len = self.visible_len(left_text);
122        let right_len = self.visible_len(right_text);
123        let sep_len = inner_width.saturating_sub(left_len + right_len + 2);
124
125        let mut output = String::new();
126        for _ in 0..inner_width {
127            output.push(box_chars::THIN_HORIZONTAL);
128        }
129        output.push('\n');
130        output.push_str(left_text);
131        for _ in 0..sep_len {
132            output.push(' ');
133        }
134        output.push_str(right_text);
135        output.push('\n');
136        for _ in 0..inner_width {
137            output.push(box_chars::THIN_HORIZONTAL);
138        }
139        output.push('\n');
140
141        output
142    }
143
144    /// Truncate or pad a string to fit width
145    fn truncate_or_pad(&self, s: &str, width: usize) -> String {
146        let visible_len = self.visible_len(s);
147        if visible_len > width {
148            let mut result = String::new();
149            let mut visible_count = 0;
150            let mut chars = s.chars().peekable();
151
152            while let Some(c) = chars.next() {
153                if c == '\x1b' {
154                    result.push(c);
155                    while let Some(&next) = chars.peek() {
156                        result.push(chars.next().unwrap());
157                        if next == 'm' {
158                            break;
159                        }
160                    }
161                } else {
162                    if visible_count >= width - 3 {
163                        result.push_str("...");
164                        result.push_str(colors::RESET);
165                        break;
166                    }
167                    result.push(c);
168                    visible_count += 1;
169                }
170            }
171            result
172        } else {
173            let padding = width - visible_len;
174            format!("{}{}", s, " ".repeat(padding))
175        }
176    }
177
178    /// Calculate visible length (excluding ANSI codes)
179    fn visible_len(&self, s: &str) -> usize {
180        let mut len = 0;
181        let mut in_escape = false;
182
183        for c in s.chars() {
184            if c == '\x1b' {
185                in_escape = true;
186            } else if in_escape {
187                if c == 'm' {
188                    in_escape = false;
189                }
190            } else {
191                len += 1;
192            }
193        }
194
195        len
196    }
197
198    /// Color a string based on reference kind
199    fn color_ref_kind(&self, kind: ReferenceKind) -> &'static str {
200        match kind {
201            ReferenceKind::Read => colors::CYAN,
202            ReferenceKind::Write => colors::YELLOW,
203            ReferenceKind::Call => colors::GREEN,
204            ReferenceKind::TypeAnnotation => colors::MAGENTA,
205            ReferenceKind::Import => colors::BLUE,
206            ReferenceKind::Export => colors::BLUE,
207        }
208    }
209
210    /// Color a string based on risk level
211    fn color_risk(&self, risk: RiskLevel) -> &'static str {
212        match risk {
213            RiskLevel::Low => colors::GREEN,
214            RiskLevel::Medium => colors::YELLOW,
215            RiskLevel::High => colors::RED,
216            RiskLevel::Critical => colors::BG_RED,
217        }
218    }
219}
220
221impl Default for AsciiFormatter {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl TraceFormatter for AsciiFormatter {
228    fn format_trace(&self, result: &TraceResult) -> String {
229        let mut output = String::new();
230
231        let defined_at = result.defined_at.as_deref().unwrap_or("unknown");
232        let header_lines = [
233            &format!(
234                "{}{}TRACE:{} {}",
235                colors::BOLD,
236                colors::CYAN,
237                colors::RESET,
238                result.symbol
239            ),
240            &format!("{}Defined:{} {}", colors::DIM, colors::RESET, defined_at),
241            &format!(
242                "{}Found:{} {} invocation paths from {} entry points",
243                colors::DIM,
244                colors::RESET,
245                result.total_paths,
246                result.entry_points
247            ),
248        ];
249        output.push_str(&self.draw_header_box(&header_lines));
250        output.push('\n');
251
252        for (i, path) in result.invocation_paths.iter().enumerate() {
253            let path_header = format!(
254                "{}{}Path {}/{}{}",
255                colors::BOLD,
256                colors::WHITE,
257                i + 1,
258                result.total_paths,
259                colors::RESET
260            );
261            let entry_info = format!("{}{}{}", colors::GREEN, path.entry_point, colors::RESET);
262            output.push_str(&self.draw_separator(&path_header, &entry_info));
263            output.push('\n');
264
265            let max_file_width = path
266                .chain
267                .iter()
268                .map(|s| s.file.len() + format!(":{}", s.line).len())
269                .max()
270                .unwrap_or(20);
271
272            for (j, step) in path.chain.iter().enumerate() {
273                let is_target = j == path.chain.len() - 1;
274                let location = format!("{}:{}", step.file, step.line);
275                let padding = max_file_width.saturating_sub(location.len()) + 2;
276
277                if is_target {
278                    output.push_str(&format!(
279                        "  {}{:<width$}{}  {}  {}{}{}  {}{} TARGET{}",
280                        colors::DIM,
281                        location,
282                        colors::RESET,
283                        box_chars::ARROW_RIGHT,
284                        colors::BOLD,
285                        colors::GREEN,
286                        step.symbol,
287                        colors::YELLOW,
288                        box_chars::TARGET,
289                        colors::RESET,
290                        width = max_file_width + padding
291                    ));
292                } else {
293                    output.push_str(&format!(
294                        "  {}{:<width$}{}  {}  {}{}{}",
295                        colors::DIM,
296                        location,
297                        colors::RESET,
298                        box_chars::ARROW_RIGHT,
299                        colors::CYAN,
300                        step.symbol,
301                        colors::RESET,
302                        width = max_file_width + padding
303                    ));
304                }
305                output.push('\n');
306
307                // Show context if available
308                if let Some(ref ctx) = step.context {
309                    for line in ctx.lines() {
310                        output.push_str(&format!(
311                            "      {}{}{}\n",
312                            colors::DIM,
313                            line,
314                            colors::RESET
315                        ));
316                    }
317                }
318
319                if !is_target {
320                    output.push_str(&format!(
321                        "  {}{:<width$}{}  {}",
322                        colors::DIM,
323                        "",
324                        colors::RESET,
325                        box_chars::ARROW_DOWN,
326                        width = max_file_width + padding
327                    ));
328                    output.push('\n');
329                }
330            }
331            output.push('\n');
332        }
333
334        output
335    }
336
337    fn format_refs(&self, result: &RefsResult) -> String {
338        let mut output = String::new();
339
340        let defined_at = result.defined_at.as_deref().unwrap_or("unknown");
341        let header_lines = [
342            &format!(
343                "{}{}REFS:{} {}",
344                colors::BOLD,
345                colors::CYAN,
346                colors::RESET,
347                result.symbol
348            ),
349            &format!("{}Defined:{} {}", colors::DIM, colors::RESET, defined_at),
350            &format!(
351                "{}Found:{} {} references",
352                colors::DIM,
353                colors::RESET,
354                result.total_refs
355            ),
356        ];
357        output.push_str(&self.draw_header_box(&header_lines));
358        output.push('\n');
359
360        if !result.by_kind.is_empty() {
361            output.push_str(&format!("{}By kind:{} ", colors::DIM, colors::RESET));
362            let kinds: Vec<_> = result
363                .by_kind
364                .iter()
365                .map(|(k, v)| format!("{}={}", k, v))
366                .collect();
367            output.push_str(&kinds.join(", "));
368            output.push_str("\n\n");
369        }
370
371        let mut by_file: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
372        for r in &result.references {
373            by_file.entry(&r.file).or_default().push(r);
374        }
375
376        for (file, refs) in by_file {
377            output.push_str(&format!(
378                "{}{}{}:{}\n",
379                colors::BOLD,
380                colors::WHITE,
381                file,
382                colors::RESET
383            ));
384
385            for r in refs {
386                let kind_color = self.color_ref_kind(r.kind);
387                output.push_str(&format!(
388                    "  {}:{:<4}  {}{:<6}{}  ",
389                    r.line,
390                    r.column,
391                    kind_color,
392                    r.kind,
393                    colors::RESET,
394                ));
395
396                // Handle multi-line context
397                let context_lines: Vec<&str> = r.context.lines().collect();
398                if context_lines.len() > 1 {
399                    output.push('\n');
400                    for line in &context_lines {
401                        output.push_str(&format!("      {}\n", line));
402                    }
403                } else {
404                    output.push_str(r.context.trim());
405                    output.push('\n');
406                }
407
408                if let Some(ref enclosing) = r.enclosing_symbol {
409                    output.push_str(&format!(
410                        "      {}(in {}){}",
411                        colors::DIM,
412                        enclosing,
413                        colors::RESET
414                    ));
415                    output.push('\n');
416                }
417            }
418            output.push('\n');
419        }
420
421        output
422    }
423
424    fn format_dead_code(&self, result: &DeadCodeResult) -> String {
425        let mut output = String::new();
426
427        let header_lines = [
428            &format!(
429                "{}{}DEAD CODE ANALYSIS{}",
430                colors::BOLD,
431                colors::YELLOW,
432                colors::RESET
433            ),
434            &format!(
435                "{}Found:{} {} unused symbols",
436                colors::DIM,
437                colors::RESET,
438                result.total_dead
439            ),
440        ];
441        output.push_str(&self.draw_header_box(&header_lines));
442        output.push('\n');
443
444        if !result.by_kind.is_empty() {
445            output.push_str(&format!("{}By kind:{} ", colors::DIM, colors::RESET));
446            let kinds: Vec<_> = result
447                .by_kind
448                .iter()
449                .map(|(k, v)| format!("{}={}", k, v))
450                .collect();
451            output.push_str(&kinds.join(", "));
452            output.push_str("\n\n");
453        }
454
455        for sym in &result.symbols {
456            output.push_str(&format!(
457                "  {}{}{}  {}{}:{}{}  {}\n",
458                colors::YELLOW,
459                sym.name,
460                colors::RESET,
461                colors::DIM,
462                sym.file,
463                sym.line,
464                colors::RESET,
465                sym.reason
466            ));
467
468            // Show potential callers if cross-referencing is enabled
469            if !sym.potential_callers.is_empty() {
470                output.push_str(&format!(
471                    "      {}Potential callers:{}\n",
472                    colors::DIM,
473                    colors::RESET
474                ));
475                for caller in &sym.potential_callers {
476                    output.push_str(&format!(
477                        "        {}→{} {}  {}{}:{}{}  {}{}{}\n",
478                        colors::GREEN,
479                        colors::RESET,
480                        caller.name,
481                        colors::DIM,
482                        caller.file,
483                        caller.line,
484                        colors::RESET,
485                        colors::DIM,
486                        caller.reason,
487                        colors::RESET
488                    ));
489                }
490            }
491        }
492
493        output
494    }
495
496    fn format_flow(&self, result: &FlowResult) -> String {
497        let mut output = String::new();
498
499        let header_lines = [
500            &format!(
501                "{}{}DATA FLOW:{} {}",
502                colors::BOLD,
503                colors::MAGENTA,
504                colors::RESET,
505                result.symbol
506            ),
507            &format!(
508                "{}Paths:{} {}",
509                colors::DIM,
510                colors::RESET,
511                result.flow_paths.len()
512            ),
513        ];
514        output.push_str(&self.draw_header_box(&header_lines));
515        output.push('\n');
516
517        for (i, path) in result.flow_paths.iter().enumerate() {
518            output.push_str(&format!(
519                "{}{}Flow Path {}{}:\n",
520                colors::BOLD,
521                colors::WHITE,
522                i + 1,
523                colors::RESET
524            ));
525
526            for step in path {
527                let action_color = match step.action {
528                    super::FlowAction::Define | super::FlowAction::Assign => colors::GREEN,
529                    super::FlowAction::Read => colors::CYAN,
530                    super::FlowAction::PassToFunction => colors::YELLOW,
531                    super::FlowAction::ReturnFrom => colors::MAGENTA,
532                    super::FlowAction::Mutate => colors::RED,
533                };
534
535                output.push_str(&format!(
536                    "  {}:{:<4}  {}{:<8}{}  {}\n",
537                    step.file,
538                    step.line,
539                    action_color,
540                    step.action,
541                    colors::RESET,
542                    step.expression.trim()
543                ));
544            }
545            output.push('\n');
546        }
547
548        output
549    }
550
551    fn format_impact(&self, result: &ImpactResult) -> String {
552        let mut output = String::new();
553
554        let risk_color = self.color_risk(result.risk_level);
555        let header_lines = [
556            &format!(
557                "{}{}IMPACT ANALYSIS:{} {}",
558                colors::BOLD,
559                colors::RED,
560                colors::RESET,
561                result.symbol
562            ),
563            &format!("{}File:{} {}", colors::DIM, colors::RESET, result.file),
564            &format!(
565                "{}Risk Level:{} {}{}{}{}",
566                colors::DIM,
567                colors::RESET,
568                colors::BOLD,
569                risk_color,
570                result.risk_level,
571                colors::RESET
572            ),
573        ];
574        output.push_str(&self.draw_header_box(&header_lines));
575        output.push('\n');
576
577        output.push_str(&format!(
578            "{}Direct callers ({}):{}\n",
579            colors::BOLD,
580            result.direct_caller_count,
581            colors::RESET
582        ));
583        for caller in &result.direct_callers {
584            output.push_str(&format!("  {} {}\n", box_chars::ARROW_RIGHT, caller));
585        }
586        output.push('\n');
587
588        if !result.transitive_callers.is_empty() {
589            output.push_str(&format!(
590                "{}Transitive callers ({}):{}\n",
591                colors::BOLD,
592                result.transitive_caller_count,
593                colors::RESET
594            ));
595            for caller in result.transitive_callers.iter().take(10) {
596                output.push_str(&format!(
597                    "  {} {}{}{}\n",
598                    box_chars::ARROW_RIGHT,
599                    colors::DIM,
600                    caller,
601                    colors::RESET
602                ));
603            }
604            if result.transitive_callers.len() > 10 {
605                output.push_str(&format!(
606                    "  {}... and {} more{}\n",
607                    colors::DIM,
608                    result.transitive_callers.len() - 10,
609                    colors::RESET
610                ));
611            }
612            output.push('\n');
613        }
614
615        output.push_str(&format!(
616            "{}Affected entry points ({}):{}\n",
617            colors::BOLD,
618            result.affected_entry_points.len(),
619            colors::RESET
620        ));
621        for ep in &result.affected_entry_points {
622            output.push_str(&format!(
623                "  {} {}{}{}\n",
624                box_chars::ARROW_RIGHT,
625                colors::GREEN,
626                ep,
627                colors::RESET
628            ));
629        }
630
631        output.push_str(&format!(
632            "\n{}Files affected:{} {}\n",
633            colors::DIM,
634            colors::RESET,
635            result.files_affected.len()
636        ));
637
638        output
639    }
640
641    fn format_module(&self, result: &ModuleResult) -> String {
642        let mut output = String::new();
643
644        let header_lines = [
645            &format!(
646                "{}{}MODULE:{} {}",
647                colors::BOLD,
648                colors::BLUE,
649                colors::RESET,
650                result.module
651            ),
652            &format!("{}Path:{} {}", colors::DIM, colors::RESET, result.file_path),
653        ];
654        output.push_str(&self.draw_header_box(&header_lines));
655        output.push('\n');
656
657        if !result.exports.is_empty() {
658            output.push_str(&format!(
659                "{}Exports ({}):{}\n",
660                colors::BOLD,
661                result.exports.len(),
662                colors::RESET
663            ));
664            for export in &result.exports {
665                output.push_str(&format!(
666                    "  {} {}{}{}\n",
667                    box_chars::ARROW_RIGHT,
668                    colors::GREEN,
669                    export,
670                    colors::RESET
671                ));
672            }
673            output.push('\n');
674        }
675
676        if !result.imported_by.is_empty() {
677            output.push_str(&format!(
678                "{}Imported by ({}):{}\n",
679                colors::BOLD,
680                result.imported_by.len(),
681                colors::RESET
682            ));
683            for importer in &result.imported_by {
684                output.push_str(&format!("  {} {}\n", box_chars::ARROW_RIGHT, importer));
685            }
686            output.push('\n');
687        }
688
689        if !result.dependencies.is_empty() {
690            output.push_str(&format!(
691                "{}Dependencies ({}):{}\n",
692                colors::BOLD,
693                result.dependencies.len(),
694                colors::RESET
695            ));
696            for dep in &result.dependencies {
697                output.push_str(&format!(
698                    "  {} {}{}{}\n",
699                    box_chars::ARROW_RIGHT,
700                    colors::CYAN,
701                    dep,
702                    colors::RESET
703                ));
704            }
705            output.push('\n');
706        }
707
708        if !result.circular_deps.is_empty() {
709            output.push_str(&format!(
710                "{}{}CIRCULAR DEPENDENCIES ({}):{}\n",
711                colors::BOLD,
712                colors::RED,
713                result.circular_deps.len(),
714                colors::RESET
715            ));
716            for cycle in &result.circular_deps {
717                output.push_str(&format!(
718                    "  {}⚠ {}{}\n",
719                    colors::YELLOW,
720                    cycle,
721                    colors::RESET
722                ));
723            }
724        }
725
726        output
727    }
728
729    fn format_pattern(&self, result: &PatternResult) -> String {
730        let mut output = String::new();
731
732        let header_lines = [
733            &format!(
734                "{}{}PATTERN:{} {}",
735                colors::BOLD,
736                colors::MAGENTA,
737                colors::RESET,
738                result.pattern
739            ),
740            &format!(
741                "{}Found:{} {} matches in {} files",
742                colors::DIM,
743                colors::RESET,
744                result.total_matches,
745                result.by_file.len()
746            ),
747        ];
748        output.push_str(&self.draw_header_box(&header_lines));
749        output.push('\n');
750
751        let mut by_file: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
752        for m in &result.matches {
753            by_file.entry(&m.file).or_default().push(m);
754        }
755
756        for (file, matches) in by_file {
757            output.push_str(&format!(
758                "{}{}{}:{}\n",
759                colors::BOLD,
760                colors::WHITE,
761                file,
762                colors::RESET
763            ));
764
765            for m in matches {
766                // Handle multi-line context
767                let context_lines: Vec<&str> = m.context.lines().collect();
768                if context_lines.len() > 1 {
769                    for line in &context_lines {
770                        output.push_str(&format!("  {}\n", line));
771                    }
772                } else {
773                    output.push_str(&format!(
774                        "  {}:{:<4}  {}\n",
775                        m.line,
776                        m.column,
777                        m.context.trim()
778                    ));
779                }
780
781                if let Some(ref enclosing) = m.enclosing_symbol {
782                    output.push_str(&format!(
783                        "      {}(in {}){}",
784                        colors::DIM,
785                        enclosing,
786                        colors::RESET
787                    ));
788                    output.push('\n');
789                }
790            }
791            output.push('\n');
792        }
793
794        output
795    }
796
797    fn format_scope(&self, result: &ScopeResult) -> String {
798        let mut output = String::new();
799
800        let scope_name = result.enclosing_scope.as_deref().unwrap_or("<global>");
801        let header_lines = [
802            &format!(
803                "{}{}SCOPE AT:{} {}:{}",
804                colors::BOLD,
805                colors::CYAN,
806                colors::RESET,
807                result.file,
808                result.line
809            ),
810            &format!("{}Enclosing:{} {}", colors::DIM, colors::RESET, scope_name),
811        ];
812        output.push_str(&self.draw_header_box(&header_lines));
813        output.push('\n');
814
815        if !result.local_variables.is_empty() {
816            output.push_str(&format!(
817                "{}Local Variables ({}):{}\n",
818                colors::BOLD,
819                result.local_variables.len(),
820                colors::RESET
821            ));
822            for var in &result.local_variables {
823                output.push_str(&format!(
824                    "  {}{}{}: {} {}(line {}){}",
825                    colors::CYAN,
826                    var.name,
827                    colors::RESET,
828                    var.kind,
829                    colors::DIM,
830                    var.defined_at,
831                    colors::RESET
832                ));
833                output.push('\n');
834            }
835            output.push('\n');
836        }
837
838        if !result.parameters.is_empty() {
839            output.push_str(&format!(
840                "{}Parameters ({}):{}\n",
841                colors::BOLD,
842                result.parameters.len(),
843                colors::RESET
844            ));
845            for param in &result.parameters {
846                output.push_str(&format!(
847                    "  {}{}{}: {}\n",
848                    colors::YELLOW,
849                    param.name,
850                    colors::RESET,
851                    param.kind
852                ));
853            }
854            output.push('\n');
855        }
856
857        if !result.imports.is_empty() {
858            output.push_str(&format!(
859                "{}Imports ({}):{}\n",
860                colors::BOLD,
861                result.imports.len(),
862                colors::RESET
863            ));
864            for import in &result.imports {
865                output.push_str(&format!("  {}{}{}\n", colors::BLUE, import, colors::RESET));
866            }
867        }
868
869        output
870    }
871
872    fn format_stats(&self, result: &StatsResult) -> String {
873        let mut output = String::new();
874
875        let header_lines = [&format!(
876            "{}{}CODEBASE STATISTICS{}",
877            colors::BOLD,
878            colors::GREEN,
879            colors::RESET
880        )];
881        output.push_str(&self.draw_header_box(&header_lines));
882        output.push('\n');
883
884        // Overview
885        output.push_str(&format!("{}Overview:{}\n", colors::BOLD, colors::RESET));
886        output.push_str(&format!("  Files:        {}\n", result.total_files));
887        output.push_str(&format!("  Symbols:      {}\n", result.total_symbols));
888        output.push_str(&format!("  Tokens:       {}\n", result.total_tokens));
889        output.push_str(&format!("  References:   {}\n", result.total_references));
890        output.push_str(&format!("  Call Edges:   {}\n", result.total_edges));
891        output.push_str(&format!("  Entry Points: {}\n", result.total_entry_points));
892        output.push('\n');
893
894        // Files by extension
895        output.push_str(&format!(
896            "{}Files by extension:{}\n",
897            colors::BOLD,
898            colors::RESET
899        ));
900        let mut exts: Vec<_> = result.files_by_extension.iter().collect();
901        exts.sort_by(|a, b| b.1.cmp(a.1));
902        for (ext, count) in exts.iter().take(10) {
903            output.push_str(&format!("  .{}: {}\n", ext, count));
904        }
905        output.push('\n');
906
907        // Symbols by kind
908        output.push_str(&format!(
909            "{}Symbols by kind:{}\n",
910            colors::BOLD,
911            colors::RESET
912        ));
913        let mut kinds: Vec<_> = result.symbols_by_kind.iter().collect();
914        kinds.sort_by(|a, b| b.1.cmp(a.1));
915        for (kind, count) in &kinds {
916            output.push_str(&format!("  {}: {}\n", kind, count));
917        }
918        output.push('\n');
919
920        // Call graph
921        output.push_str(&format!("{}Call Graph:{}\n", colors::BOLD, colors::RESET));
922        output.push_str(&format!("  Max Call Depth: {}\n", result.max_call_depth));
923        output.push_str(&format!("  Avg Call Depth: {:.1}\n", result.avg_call_depth));
924        output.push('\n');
925
926        // Most referenced
927        if !result.most_referenced.is_empty() {
928            output.push_str(&format!(
929                "{}Most Referenced Symbols:{}\n",
930                colors::BOLD,
931                colors::RESET
932            ));
933            for (name, count) in result.most_referenced.iter().take(10) {
934                output.push_str(&format!(
935                    "  {}{}{}: {} refs\n",
936                    colors::CYAN,
937                    name,
938                    colors::RESET,
939                    count
940                ));
941            }
942            output.push('\n');
943        }
944
945        // Largest files
946        if !result.largest_files.is_empty() {
947            output.push_str(&format!(
948                "{}Largest Files (by symbols):{}\n",
949                colors::BOLD,
950                colors::RESET
951            ));
952            for (file, count) in result.largest_files.iter().take(10) {
953                output.push_str(&format!("  {}: {} symbols\n", file, count));
954            }
955        }
956
957        output
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn test_visible_len() {
967        let formatter = AsciiFormatter::new();
968        assert_eq!(formatter.visible_len("hello"), 5);
969        assert_eq!(formatter.visible_len("\x1b[32mhello\x1b[0m"), 5);
970        assert_eq!(formatter.visible_len("\x1b[1m\x1b[32mtest\x1b[0m"), 4);
971    }
972
973    #[test]
974    fn test_format_trace_basic() {
975        let formatter = AsciiFormatter::new();
976        let result = TraceResult {
977            symbol: "validateUser".to_string(),
978            defined_at: Some("utils/validation.ts:8".to_string()),
979            kind: "function".to_string(),
980            invocation_paths: vec![],
981            total_paths: 0,
982            entry_points: 0,
983        };
984        let output = formatter.format_trace(&result);
985        assert!(output.contains("validateUser"));
986        assert!(output.contains("TRACE"));
987    }
988}