Skip to main content

leta_output/
formatters.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use leta_types::*;
5
6pub fn format_truncation_with_count(
7    command_with_larger_head: &str,
8    displayed_count: u32,
9    total_count: u32,
10    command_base: &str,
11) -> String {
12    format!(
13        "[showing {} of {} results, use `{}` to show more, or `{} -N0` to show all]",
14        displayed_count, total_count, command_with_larger_head, command_base
15    )
16}
17
18pub fn format_truncation_unknown_total(
19    command_with_larger_head: &str,
20    displayed_count: u32,
21    command_base: &str,
22) -> String {
23    format!(
24        "[showing first {} results, use `{}` to show more, or `{} -N0` to show all]",
25        displayed_count, command_with_larger_head, command_base
26    )
27}
28
29pub fn format_grep_result(result: &GrepResult, head: u32, command_base: &str) -> String {
30    if let Some(warning) = &result.warning {
31        return format!("Warning: {}", warning);
32    }
33    let mut output = format_symbols(&result.symbols);
34
35    if result.truncated {
36        if !output.is_empty() {
37            output.push_str("\n\n");
38        }
39        let next_head = head * 2;
40        let cmd = format!("{} --head {}", command_base, next_head);
41        if let Some(total) = result.total_count {
42            output.push_str(&format_truncation_with_count(
43                &cmd,
44                result.symbols.len() as u32,
45                total,
46                command_base,
47            ));
48        } else {
49            output.push_str(&format_truncation_unknown_total(
50                &cmd,
51                result.symbols.len() as u32,
52                command_base,
53            ));
54        }
55    }
56
57    output
58}
59
60pub fn format_references_result(
61    result: &ReferencesResult,
62    head: u32,
63    command_base: &str,
64) -> String {
65    let mut output = format_locations(&result.locations);
66
67    if result.truncated {
68        if !output.is_empty() {
69            output.push('\n');
70        }
71        let next_head = head * 2;
72        let cmd = format!("{} --head {}", command_base, next_head);
73        if let Some(total) = result.total_count {
74            output.push_str(&format_truncation_with_count(
75                &cmd,
76                result.locations.len() as u32,
77                total,
78                command_base,
79            ));
80        } else {
81            output.push_str(&format_truncation_unknown_total(
82                &cmd,
83                result.locations.len() as u32,
84                command_base,
85            ));
86        }
87    }
88
89    output
90}
91
92pub fn format_declaration_result(
93    result: &DeclarationResult,
94    head: u32,
95    command_base: &str,
96) -> String {
97    let mut output = format_locations(&result.locations);
98
99    if result.truncated {
100        if !output.is_empty() {
101            output.push('\n');
102        }
103        let next_head = head * 2;
104        let cmd = format!("{} --head {}", command_base, next_head);
105        if let Some(total) = result.total_count {
106            output.push_str(&format_truncation_with_count(
107                &cmd,
108                result.locations.len() as u32,
109                total,
110                command_base,
111            ));
112        } else {
113            output.push_str(&format_truncation_unknown_total(
114                &cmd,
115                result.locations.len() as u32,
116                command_base,
117            ));
118        }
119    }
120
121    output
122}
123
124pub fn format_implementations_result(
125    result: &ImplementationsResult,
126    head: u32,
127    command_base: &str,
128) -> String {
129    if let Some(error) = &result.error {
130        return format!("Error: {}", error);
131    }
132    let mut output = format_locations(&result.locations);
133
134    if result.truncated {
135        if !output.is_empty() {
136            output.push('\n');
137        }
138        let next_head = head * 2;
139        let cmd = format!("{} --head {}", command_base, next_head);
140        if let Some(total) = result.total_count {
141            output.push_str(&format_truncation_with_count(
142                &cmd,
143                result.locations.len() as u32,
144                total,
145                command_base,
146            ));
147        } else {
148            output.push_str(&format_truncation_unknown_total(
149                &cmd,
150                result.locations.len() as u32,
151                command_base,
152            ));
153        }
154    }
155
156    output
157}
158
159pub fn format_subtypes_result(result: &SubtypesResult, head: u32, command_base: &str) -> String {
160    let mut output = format_locations(&result.locations);
161
162    if result.truncated {
163        if !output.is_empty() {
164            output.push('\n');
165        }
166        let next_head = head * 2;
167        let cmd = format!("{} --head {}", command_base, next_head);
168        if let Some(total) = result.total_count {
169            output.push_str(&format_truncation_with_count(
170                &cmd,
171                result.locations.len() as u32,
172                total,
173                command_base,
174            ));
175        } else {
176            output.push_str(&format_truncation_unknown_total(
177                &cmd,
178                result.locations.len() as u32,
179                command_base,
180            ));
181        }
182    }
183
184    output
185}
186
187pub fn format_supertypes_result(
188    result: &SupertypesResult,
189    head: u32,
190    command_base: &str,
191) -> String {
192    let mut output = format_locations(&result.locations);
193
194    if result.truncated {
195        if !output.is_empty() {
196            output.push('\n');
197        }
198        let next_head = head * 2;
199        let cmd = format!("{} --head {}", command_base, next_head);
200        if let Some(total) = result.total_count {
201            output.push_str(&format_truncation_with_count(
202                &cmd,
203                result.locations.len() as u32,
204                total,
205                command_base,
206            ));
207        } else {
208            output.push_str(&format_truncation_unknown_total(
209                &cmd,
210                result.locations.len() as u32,
211                command_base,
212            ));
213        }
214    }
215
216    output
217}
218
219pub fn format_show_result(result: &ShowResult, head: u32) -> String {
220    let location = if result.start_line == result.end_line {
221        format!("{}:{}", result.path, result.start_line)
222    } else {
223        format!("{}:{}-{}", result.path, result.start_line, result.end_line)
224    };
225
226    let mut lines = vec![location, String::new(), result.content.clone()];
227
228    if result.truncated {
229        let total_lines = result.total_lines.unwrap_or(head);
230        let symbol = result.symbol.as_deref().unwrap_or("SYMBOL");
231        lines.push(String::new());
232        lines.push(format!(
233            "[truncated after {} lines, use `leta show \"{}\" --head {}` to show the full {} lines]",
234            head, symbol, total_lines, total_lines
235        ));
236    }
237
238    lines.join("\n")
239}
240
241pub fn format_rename_result(result: &RenameResult) -> String {
242    let mut files: Vec<_> = result.files_changed.iter().collect();
243    files.sort();
244    format!(
245        "Renamed in {} file(s):\n{}",
246        files.len(),
247        files
248            .iter()
249            .map(|f| format!("  {}", f))
250            .collect::<Vec<_>>()
251            .join("\n")
252    )
253}
254
255pub fn format_move_file_result(result: &MoveFileResult) -> String {
256    let mut files: Vec<_> = result.files_changed.iter().collect();
257    files.sort();
258    if result.imports_updated {
259        format!(
260            "Moved file and updated imports in {} file(s):\n{}",
261            files.len(),
262            files
263                .iter()
264                .map(|f| format!("  {}", f))
265                .collect::<Vec<_>>()
266                .join("\n")
267        )
268    } else if let Some(first) = files.first() {
269        format!("Moved file (imports not updated):\n  {}", first)
270    } else {
271        "File moved".to_string()
272    }
273}
274
275pub fn format_restart_workspace_result(result: &RestartWorkspaceResult) -> String {
276    format!(
277        "Restarted {} server(s): {}",
278        result.restarted.len(),
279        result.restarted.join(", ")
280    )
281}
282
283pub fn format_remove_workspace_result(result: &RemoveWorkspaceResult) -> String {
284    format!(
285        "Stopped {} server(s): {}",
286        result.servers_stopped.len(),
287        result.servers_stopped.join(", ")
288    )
289}
290
291pub fn format_files_result(result: &FilesResult, head: u32, command_base: &str) -> String {
292    if result.files.is_empty() && result.excluded_dirs.is_empty() {
293        return String::new();
294    }
295
296    let tree = build_tree(&result.files, &result.excluded_dirs);
297    let mut lines = Vec::new();
298    render_tree(&tree, &mut lines, 0);
299
300    if result.truncated {
301        lines.push(String::new());
302        let next_head = head * 2;
303        let cmd = format!("{} --head {}", command_base, next_head);
304        lines.push(format_truncation_unknown_total(
305            &cmd,
306            result.files.len() as u32,
307            command_base,
308        ));
309    }
310
311    lines.join("\n")
312}
313
314pub fn format_calls_result(result: &CallsResult, head: u32, command_base: &str) -> String {
315    if let Some(error) = &result.error {
316        return format!("Error: {}", error);
317    }
318    if let Some(message) = &result.message {
319        return message.clone();
320    }
321    let mut output = String::new();
322    if let Some(root) = &result.root {
323        output = format_call_tree(root);
324    } else if let Some(path) = &result.path {
325        output = format_call_path(path);
326    }
327
328    if result.truncated {
329        if !output.is_empty() {
330            output.push_str("\n\n");
331        }
332        let next_head = head * 2;
333        let cmd = format!("{} --head {}", command_base, next_head);
334        output.push_str(&format_truncation_unknown_total(&cmd, head, command_base));
335    }
336
337    output
338}
339
340pub fn format_describe_session_result(
341    result: &DescribeSessionResult,
342    show_profiling: bool,
343) -> String {
344    let mut lines = vec![format!("Daemon PID: {}", result.daemon_pid)];
345
346    if !result.caches.is_empty() {
347        lines.push("\nCaches:".to_string());
348        if let Some(hover) = result.caches.get("hover_cache") {
349            lines.push(format!(
350                "  Hover:  {} / {} ({} entries)",
351                format_size(hover.current_bytes),
352                format_size(hover.max_bytes),
353                hover.entries
354            ));
355        }
356        if let Some(symbol) = result.caches.get("symbol_cache") {
357            lines.push(format!(
358                "  Symbol: {} / {} ({} entries)",
359                format_size(symbol.current_bytes),
360                format_size(symbol.max_bytes),
361                symbol.entries
362            ));
363        }
364    }
365
366    let profiling_map: HashMap<&str, &WorkspaceProfilingData> = result
367        .profiling
368        .as_ref()
369        .map(|data| {
370            data.iter()
371                .map(|p| (p.workspace_root.as_str(), p))
372                .collect()
373        })
374        .unwrap_or_default();
375
376    let mut workspace_roots: std::collections::HashSet<&str> = result
377        .workspaces
378        .iter()
379        .map(|ws| ws.root.as_str())
380        .collect();
381
382    for root in profiling_map.keys() {
383        workspace_roots.insert(root);
384    }
385
386    if workspace_roots.is_empty() {
387        lines.push("\nNo active workspaces".to_string());
388    } else {
389        lines.push("\nActive workspaces:".to_string());
390
391        let mut sorted_roots: Vec<_> = workspace_roots.into_iter().collect();
392        sorted_roots.sort();
393
394        for root in sorted_roots {
395            lines.push(format!("\n  {}", root));
396
397            let workspaces_for_root: Vec<_> = result
398                .workspaces
399                .iter()
400                .filter(|ws| ws.root == root)
401                .collect();
402
403            let profiling_data = profiling_map.get(root);
404
405            for ws in &workspaces_for_root {
406                let status = if ws.server_pid.is_some() {
407                    "running"
408                } else {
409                    "stopped"
410                };
411                let pid_str = ws
412                    .server_pid
413                    .map(|p| format!(", PID {}", p))
414                    .unwrap_or_default();
415
416                lines.push(format!(
417                    "    {} ({}{}) [{} open files]",
418                    ws.language,
419                    status,
420                    pid_str,
421                    ws.open_documents.len()
422                ));
423
424                if show_profiling {
425                    if let Some(profile) = profiling_data.and_then(|p| {
426                        p.server_profiles
427                            .iter()
428                            .find(|sp| sp.server_name == ws.language)
429                    }) {
430                        if let Some(startup) = &profile.startup {
431                            lines.push(format!(
432                                "      Startup: {}ms (init: {}ms, ready: {}ms)",
433                                startup.total_time_ms, startup.init_time_ms, startup.ready_time_ms
434                            ));
435                            lines.extend(format_function_stats(&startup.functions, "        ", 5));
436                        }
437                        if let Some(indexing) = &profile.indexing {
438                            let cache = &indexing.cache;
439                            let symbol_total = cache.symbol_hits + cache.symbol_misses;
440                            let cache_str = if symbol_total > 0 {
441                                format!(
442                                    ", cache {}/{} ({:.0}%)",
443                                    cache.symbol_hits,
444                                    symbol_total,
445                                    cache.symbol_hit_rate()
446                                )
447                            } else {
448                                String::new()
449                            };
450                            lines.push(format!(
451                                "      Indexing: {}ms ({} files{})",
452                                indexing.total_time_ms, indexing.file_count, cache_str
453                            ));
454                            lines.extend(format_function_stats(
455                                &indexing.functions,
456                                "        ",
457                                10,
458                            ));
459                        }
460                    }
461                }
462            }
463
464            if show_profiling {
465                if let Some(profile) = profiling_data {
466                    lines.push(format!(
467                        "    Total: {}ms ({} files)",
468                        profile.total_time_ms, profile.total_files
469                    ));
470                }
471            }
472        }
473    }
474
475    lines.join("\n")
476}
477
478fn format_duration_us(us: u64) -> String {
479    if us >= 1_000_000 {
480        format!("{:.2}s", us as f64 / 1_000_000.0)
481    } else if us >= 1_000 {
482        format!("{:.1}ms", us as f64 / 1_000.0)
483    } else {
484        format!("{}µs", us)
485    }
486}
487
488pub fn format_function_name(name: &str) -> &str {
489    name.strip_prefix("leta_daemon::handlers::")
490        .or_else(|| name.strip_prefix("leta_daemon::"))
491        .or_else(|| name.strip_prefix("leta_lsp::"))
492        .or_else(|| name.strip_prefix("leta_"))
493        .unwrap_or(name)
494        .trim_end_matches("::{{closure}}")
495}
496
497pub fn format_function_stats(
498    functions: &[FunctionStats],
499    indent: &str,
500    max_lines: usize,
501) -> Vec<String> {
502    let mut lines = Vec::new();
503    if functions.is_empty() {
504        return lines;
505    }
506    lines.push(format!(
507        "{}{:<50} {:>6} {:>10} {:>10} {:>10}",
508        indent, "Function", "Calls", "Avg", "P90", "Total"
509    ));
510    for func in functions.iter().take(max_lines) {
511        let name = format_function_name(&func.name);
512        lines.push(format!(
513            "{}{:<50} {:>6} {:>10} {:>10} {:>10}",
514            indent,
515            name,
516            func.calls,
517            format_duration_us(func.avg_us),
518            format_duration_us(func.p90_us),
519            format_duration_us(func.total_us),
520        ));
521    }
522    lines
523}
524
525pub fn format_resolve_symbol_result(result: &ResolveSymbolResult) -> String {
526    if let Some(error) = &result.error {
527        let mut lines = vec![format!("Error: {}", error)];
528        if let Some(matches) = &result.matches {
529            for m in matches {
530                let container = m
531                    .container
532                    .as_ref()
533                    .map(|c| format!(" in {}", c))
534                    .unwrap_or_default();
535                let kind = format!("[{}] ", m.kind);
536                let detail = m
537                    .detail
538                    .as_ref()
539                    .map(|d| format!(" ({})", d))
540                    .unwrap_or_default();
541                let ref_str = m.reference.as_deref().unwrap_or("");
542                lines.push(format!("  {}", ref_str));
543                lines.push(format!(
544                    "    {}:{} {}{}{}{}",
545                    m.path, m.line, kind, m.name, detail, container
546                ));
547            }
548            if let Some(total) = result.total_matches {
549                let shown = matches.len() as u32;
550                if total > shown {
551                    lines.push(format!("  ... and {} more", total - shown));
552                }
553            }
554        }
555        return lines.join("\n");
556    }
557    format!(
558        "{}:{}",
559        result.path.as_deref().unwrap_or(""),
560        result.line.unwrap_or(0)
561    )
562}
563
564fn format_locations(locations: &[LocationInfo]) -> String {
565    let mut lines = Vec::new();
566    for loc in locations {
567        if let (Some(name), Some(kind)) = (&loc.name, &loc.kind) {
568            let mut parts = vec![
569                format!("{}:{}", loc.path, loc.line),
570                format!("[{}]", kind),
571                name.clone(),
572            ];
573            if let Some(detail) = &loc.detail {
574                if !detail.is_empty() && detail != "()" {
575                    parts.push(format!("({})", detail));
576                }
577            }
578            lines.push(parts.join(" "));
579        } else if let Some(context) = &loc.context_lines {
580            let context_start = loc.context_start.unwrap_or(loc.line);
581            let context_end = context_start + context.len() as u32 - 1;
582            lines.push(format!("{}:{}-{}", loc.path, context_start, context_end));
583            for line in context {
584                lines.push(line.clone());
585            }
586            lines.push(String::new());
587        } else {
588            let line_content = get_line_content(&loc.path, loc.line);
589            if let Some(content) = line_content {
590                lines.push(format!("{}:{} {}", loc.path, loc.line, content));
591            } else {
592                lines.push(format!("{}:{}", loc.path, loc.line));
593            }
594        }
595    }
596    lines.join("\n")
597}
598
599fn get_line_content(path: &str, line: u32) -> Option<String> {
600    let file_path = PathBuf::from(path);
601    let file_path = if file_path.is_absolute() {
602        file_path
603    } else {
604        std::env::current_dir().ok()?.join(&file_path)
605    };
606
607    let content = std::fs::read_to_string(&file_path).ok()?;
608    let lines: Vec<&str> = content.lines().collect();
609    if line > 0 && (line as usize) <= lines.len() {
610        Some(lines[line as usize - 1].to_string())
611    } else {
612        None
613    }
614}
615
616pub fn format_file_line(file: &FileInfo) -> String {
617    format!(
618        "{} ({}, {} lines)",
619        file.path,
620        format_size(file.bytes),
621        file.lines
622    )
623}
624
625/// Stateful printer for streaming file output with proper indentation.
626/// Tracks the current directory path and emits directory headers when needed.
627pub struct FileTreePrinter {
628    current_path: Vec<String>,
629}
630
631impl FileTreePrinter {
632    pub fn new() -> Self {
633        Self {
634            current_path: Vec::new(),
635        }
636    }
637
638    /// Format a file with proper indentation, emitting directory headers as needed.
639    /// Returns a string that may contain multiple lines (directory headers + file).
640    pub fn format_file(&mut self, file: &FileInfo) -> String {
641        let parts: Vec<&str> = file.path.split('/').collect();
642        let (dirs, filename) = parts.split_at(parts.len().saturating_sub(1));
643
644        let mut output = String::new();
645
646        // Find where current path diverges from file path
647        let mut common_depth = 0;
648        for (i, dir) in dirs.iter().enumerate() {
649            if i < self.current_path.len() && self.current_path[i] == *dir {
650                common_depth = i + 1;
651            } else {
652                break;
653            }
654        }
655
656        // Truncate current path to common prefix
657        self.current_path.truncate(common_depth);
658
659        // Add new directory headers
660        for (i, dir) in dirs.iter().enumerate().skip(common_depth) {
661            let indent = "  ".repeat(i);
662            output.push_str(&format!("{}{}/\n", indent, dir));
663            self.current_path.push(dir.to_string());
664        }
665
666        // Add the file
667        let indent = "  ".repeat(dirs.len());
668        let info_str = format!("{}, {} lines", format_size(file.bytes), file.lines);
669        if let Some(name) = filename.first() {
670            output.push_str(&format!("{}{} ({})", indent, name, info_str));
671        }
672
673        output
674    }
675
676    pub fn format_excluded_dir(&mut self, path: &str) -> String {
677        let parts: Vec<&str> = path.split('/').collect();
678        if parts.is_empty() {
679            return String::new();
680        }
681
682        let (parent_dirs, dir_name) = parts.split_at(parts.len().saturating_sub(1));
683
684        let mut output = String::new();
685
686        let mut common_depth = 0;
687        for (i, dir) in parent_dirs.iter().enumerate() {
688            if i < self.current_path.len() && self.current_path[i] == *dir {
689                common_depth = i + 1;
690            } else {
691                break;
692            }
693        }
694
695        self.current_path.truncate(common_depth);
696
697        for (i, dir) in parent_dirs.iter().enumerate().skip(common_depth) {
698            let indent = "  ".repeat(i);
699            output.push_str(&format!("{}{}/\n", indent, dir));
700            self.current_path.push(dir.to_string());
701        }
702
703        let indent = "  ".repeat(parent_dirs.len());
704        if let Some(name) = dir_name.first() {
705            output.push_str(&format!("{}{} (excluded)", indent, name));
706        }
707
708        output
709    }
710}
711
712impl Default for FileTreePrinter {
713    fn default() -> Self {
714        Self::new()
715    }
716}
717
718pub fn format_symbol_line(sym: &SymbolInfo) -> String {
719    let location = format!("{}:{}", sym.path, sym.line);
720    let mut parts = vec![location, format!("[{}]", sym.kind), sym.name.clone()];
721    if let Some(detail) = &sym.detail {
722        if !detail.is_empty() && detail != "()" {
723            parts.push(format!("({})", detail));
724        }
725    }
726    if let Some(container) = &sym.container {
727        parts.push(format!("in {}", container));
728    }
729    parts.join(" ")
730}
731
732fn format_symbols(symbols: &[SymbolInfo]) -> String {
733    let mut lines = Vec::new();
734    for sym in symbols {
735        lines.push(format_symbol_line(sym));
736    }
737    lines.join("\n")
738}
739
740pub fn format_size(size: u64) -> String {
741    if size < 1024 {
742        format!("{}B", size)
743    } else if size < 1024 * 1024 {
744        format!("{:.1}KB", size as f64 / 1024.0)
745    } else {
746        format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
747    }
748}
749
750pub fn format_profiling(profiling: &ProfilingData) -> String {
751    let mut lines = Vec::new();
752
753    let cache = &profiling.cache;
754    let symbol_total = cache.symbol_hits + cache.symbol_misses;
755    let hover_total = cache.hover_hits + cache.hover_misses;
756
757    if symbol_total > 0 || hover_total > 0 {
758        lines.push("CACHE".to_string());
759        if symbol_total > 0 {
760            lines.push(format!(
761                "  symbols: {}/{} hits ({:.1}%)",
762                cache.symbol_hits,
763                symbol_total,
764                cache.symbol_hit_rate()
765            ));
766        }
767        if hover_total > 0 {
768            lines.push(format!(
769                "  hover: {}/{} hits ({:.1}%)",
770                cache.hover_hits,
771                hover_total,
772                cache.hover_hit_rate()
773            ));
774        }
775        lines.push(String::new());
776    }
777
778    if let Some(tree) = &profiling.span_tree {
779        lines.push(format!(
780            "{:<55} {:>7} {:>9} {:>9} {:>9}",
781            "Function", "Calls", "Avg", "P90", "Total"
782        ));
783        lines.push("-".repeat(90));
784
785        for root in &tree.roots {
786            format_span_node(root, &mut lines, 0, &tree.functions);
787        }
788
789        lines.push("-".repeat(90));
790        lines.push(format!(
791            "{:<55} {:>7} {:>9} {:>9} {:>9}",
792            "TOTAL",
793            "",
794            "",
795            "",
796            format_duration_us(tree.total_us)
797        ));
798    }
799
800    lines.join("\n")
801}
802
803fn get_func_stats<'a>(name: &str, functions: &'a [FunctionStats]) -> Option<&'a FunctionStats> {
804    functions.iter().find(|f| f.name == name)
805}
806
807fn format_span_node(
808    node: &SpanNode,
809    lines: &mut Vec<String>,
810    depth: usize,
811    functions: &[FunctionStats],
812) {
813    let indent = " ".repeat(depth);
814    let parallel_marker = if node.is_parallel { " ||" } else { "" };
815
816    let stats = get_func_stats(&node.name, functions);
817    let (calls, avg, p90) = if let Some(s) = stats {
818        (s.calls, s.avg_us, s.p90_us)
819    } else {
820        (
821            node.calls,
822            if node.calls > 0 {
823                node.total_us / node.calls as u64
824            } else {
825                0
826            },
827            0,
828        )
829    };
830
831    let name_with_indent = format!("{}{}{}", indent, node.name, parallel_marker);
832
833    lines.push(format!(
834        "{:<55} {:>7} {:>9} {:>9} {:>9}",
835        truncate_left(&name_with_indent, 55),
836        calls,
837        format_duration_us(avg),
838        format_duration_us(p90),
839        format_duration_us(node.total_us)
840    ));
841
842    // Show properties if any - extract timing info from properties
843    let mut property_time_ms = 0.0f64;
844    if !node.properties.is_empty() {
845        let props_indent = " ".repeat(depth + 1);
846
847        // Aggregate properties by key, summing numeric values for _ms keys
848        let mut aggregated: std::collections::HashMap<&str, (f64, u32)> =
849            std::collections::HashMap::new();
850        for (k, v) in &node.properties {
851            if k.ends_with("_ms") {
852                if let Ok(ms) = v.parse::<f64>() {
853                    property_time_ms += ms;
854                    let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
855                    entry.0 += ms;
856                    entry.1 += 1;
857                }
858            } else if let Ok(num) = v.parse::<f64>() {
859                let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
860                entry.0 += num;
861                entry.1 += 1;
862            } else {
863                // Non-numeric properties just count occurrences
864                let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
865                entry.1 += 1;
866            }
867        }
868
869        // Format aggregated properties
870        let mut props_str: Vec<String> = aggregated
871            .iter()
872            .map(|(k, (sum, count))| {
873                if *count > 1 {
874                    if k.ends_with("_ms") {
875                        format!(
876                            "{}={:.1}ms total ({} calls)",
877                            k.trim_end_matches("_ms"),
878                            sum,
879                            count
880                        )
881                    } else {
882                        format!("{}={:.0} total", k, sum)
883                    }
884                } else if k.ends_with("_ms") {
885                    format!("{}={:.1}ms", k.trim_end_matches("_ms"), sum)
886                } else {
887                    format!("{}={:.0}", k, sum)
888                }
889            })
890            .collect();
891        props_str.sort();
892
893        lines.push(format!("{}  [{}]", props_indent, props_str.join(", ")));
894    }
895
896    for child in &node.children {
897        format_span_node(child, lines, depth + 1, functions);
898    }
899
900    // Only show unaccounted if:
901    // 1. There's significant unaccounted time (> 1ms)
902    // 2. There are children (otherwise all time is "self")
903    // 3. Properties don't already explain most of the time
904    let property_time_us = (property_time_ms * 1000.0) as u64;
905    let truly_unaccounted = node.self_us.saturating_sub(property_time_us);
906
907    if truly_unaccounted > 1000 && !node.children.is_empty() {
908        let unaccounted_name = format!("{}[unaccounted]", " ".repeat(depth + 1));
909        lines.push(format!(
910            "{:<55} {:>7} {:>9} {:>9} {:>9}",
911            unaccounted_name,
912            "",
913            "",
914            "",
915            format_duration_us(truly_unaccounted)
916        ));
917    }
918}
919
920fn truncate_left(s: &str, max_len: usize) -> String {
921    if s.len() <= max_len {
922        s.to_string()
923    } else {
924        format!("…{}", &s[s.len() - max_len + 1..])
925    }
926}
927
928enum TreeNode {
929    File(FileInfo),
930    Dir(HashMap<String, TreeNode>),
931    ExcludedDir,
932}
933
934fn build_tree(
935    files: &HashMap<String, FileInfo>,
936    excluded_dirs: &[String],
937) -> HashMap<String, TreeNode> {
938    let mut tree: HashMap<String, TreeNode> = HashMap::new();
939
940    for (rel_path, info) in files {
941        let parts: Vec<&str> = rel_path.split('/').collect();
942        let mut current = &mut tree;
943
944        for (i, part) in parts.iter().enumerate() {
945            if i == parts.len() - 1 {
946                current.insert(part.to_string(), TreeNode::File(info.clone()));
947            } else {
948                current = match current
949                    .entry(part.to_string())
950                    .or_insert_with(|| TreeNode::Dir(HashMap::new()))
951                {
952                    TreeNode::Dir(map) => map,
953                    _ => unreachable!(),
954                };
955            }
956        }
957    }
958
959    for excluded_path in excluded_dirs {
960        let parts: Vec<&str> = excluded_path.split('/').collect();
961        let mut current = &mut tree;
962
963        for (i, part) in parts.iter().enumerate() {
964            if i == parts.len() - 1 {
965                current
966                    .entry(part.to_string())
967                    .or_insert(TreeNode::ExcludedDir);
968            } else {
969                current = match current
970                    .entry(part.to_string())
971                    .or_insert_with(|| TreeNode::Dir(HashMap::new()))
972                {
973                    TreeNode::Dir(map) => map,
974                    _ => break,
975                };
976            }
977        }
978    }
979
980    tree
981}
982
983fn render_tree(node: &HashMap<String, TreeNode>, lines: &mut Vec<String>, indent: usize) {
984    let mut entries: Vec<_> = node.keys().collect();
985    entries.sort();
986
987    let prefix = "  ".repeat(indent);
988
989    for name in entries {
990        let child = node.get(name).unwrap();
991
992        match child {
993            TreeNode::File(info) => {
994                let info_str = format_file_info(info);
995                lines.push(format!("{}{} ({})", prefix, name, info_str));
996            }
997            TreeNode::Dir(children) => {
998                lines.push(format!("{}{}/", prefix, name));
999                render_tree(children, lines, indent + 1);
1000            }
1001            TreeNode::ExcludedDir => {
1002                lines.push(format!("{}{} (excluded)", prefix, name));
1003            }
1004        }
1005    }
1006}
1007
1008fn format_file_info(info: &FileInfo) -> String {
1009    format!("{}, {} lines", format_size(info.bytes), info.lines)
1010}
1011
1012fn is_stdlib_path(path: &str) -> bool {
1013    path.contains("/typeshed-fallback/stdlib/")
1014        || path.contains("/typeshed/stdlib/")
1015        || (path.contains("/libexec/src/") && !path.contains("/mod/"))
1016        || (path.ends_with(".d.ts")
1017            && path
1018                .split('/')
1019                .next_back()
1020                .map(|f| f.starts_with("lib."))
1021                .unwrap_or(false))
1022        || path.contains("/rustlib/src/rust/library/")
1023}
1024
1025fn should_show_detail(detail: &Option<String>) -> bool {
1026    detail
1027        .as_ref()
1028        .map(|d| !d.is_empty() && d != "()")
1029        .unwrap_or(false)
1030}
1031
1032fn format_call_tree(node: &CallNode) -> String {
1033    let mut lines = Vec::new();
1034
1035    let mut parts: Vec<String> = Vec::new();
1036    if let Some(path) = &node.path {
1037        parts.push(format!("{}:{}", path, node.line.unwrap_or(0)));
1038    }
1039    if let Some(kind) = &node.kind {
1040        parts.push(format!("[{}]", kind));
1041    }
1042    parts.push(node.name.clone());
1043    if should_show_detail(&node.detail) {
1044        parts.push(format!("({})", node.detail.as_ref().unwrap()));
1045    }
1046    lines.push(parts.join(" "));
1047
1048    if let Some(calls) = &node.calls {
1049        lines.push(String::new());
1050        lines.push("Outgoing calls:".to_string());
1051        if !calls.is_empty() {
1052            render_calls_tree(calls, &mut lines, "  ", true);
1053        }
1054    } else if let Some(called_by) = &node.called_by {
1055        lines.push(String::new());
1056        lines.push("Incoming calls:".to_string());
1057        if !called_by.is_empty() {
1058            render_calls_tree(called_by, &mut lines, "  ", false);
1059        }
1060    }
1061
1062    lines.join("\n")
1063}
1064
1065fn render_calls_tree(items: &[CallNode], lines: &mut Vec<String>, prefix: &str, is_outgoing: bool) {
1066    for (i, item) in items.iter().enumerate() {
1067        let is_last = i == items.len() - 1;
1068        let connector = if is_last { "└── " } else { "├── " };
1069        let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
1070
1071        let path = item.path.as_deref().unwrap_or("");
1072        let line = item.line.unwrap_or(0);
1073
1074        let mut parts: Vec<String> = Vec::new();
1075        if !item.in_workspace || is_stdlib_path(path) {
1076            if let Some(kind) = &item.kind {
1077                parts.push(format!("[{}]", kind));
1078            }
1079        } else {
1080            parts.push(format!("{}:{}", path, line));
1081            if let Some(kind) = &item.kind {
1082                parts.push(format!("[{}]", kind));
1083            }
1084        }
1085        parts.push(item.name.clone());
1086        if should_show_detail(&item.detail) {
1087            parts.push(format!("({})", item.detail.as_ref().unwrap()));
1088        }
1089        lines.push(format!("{}{}{}", prefix, connector, parts.join(" ")));
1090
1091        let children = if is_outgoing {
1092            &item.calls
1093        } else {
1094            &item.called_by
1095        };
1096        if let Some(children) = children {
1097            render_calls_tree(children, lines, &child_prefix, is_outgoing);
1098        }
1099    }
1100}
1101
1102fn format_call_path(path: &[CallNode]) -> String {
1103    if path.is_empty() {
1104        return "Empty path".to_string();
1105    }
1106
1107    let mut lines = vec!["Call path:".to_string()];
1108    for (i, item) in path.iter().enumerate() {
1109        let file_path = item.path.as_deref().unwrap_or("");
1110        let line = item.line.unwrap_or(0);
1111
1112        let mut parts = vec![format!("{}:{}", file_path, line)];
1113        if let Some(kind) = &item.kind {
1114            parts.push(format!("[{}]", kind));
1115        }
1116        parts.push(item.name.clone());
1117        if should_show_detail(&item.detail) {
1118            parts.push(format!("({})", item.detail.as_ref().unwrap()));
1119        }
1120
1121        let arrow = if i == 0 { "" } else { "  → " };
1122        lines.push(format!("{}{}", arrow, parts.join(" ")));
1123    }
1124
1125    lines.join("\n")
1126}
1127
1128pub fn format_graph_result(result: &GraphResult, include_orphans: bool) -> String {
1129    use leta_types::{CallGraphEdge, CallGraphSymbol};
1130
1131    let node_key = |s: &CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1132
1133    let mut outgoing: HashMap<String, Vec<&CallGraphEdge>> = HashMap::new();
1134    let mut has_incoming: HashSet<String> = HashSet::new();
1135    let mut has_outgoing: HashSet<String> = HashSet::new();
1136    let mut self_recursive: HashSet<String> = HashSet::new();
1137    let mut node_map: HashMap<String, &CallGraphSymbol> = HashMap::new();
1138
1139    for node in &result.nodes {
1140        node_map.insert(node_key(node), node);
1141    }
1142
1143    for edge in &result.edges {
1144        let caller_key = node_key(&edge.caller);
1145        let callee_key = node_key(&edge.callee);
1146
1147        node_map.entry(callee_key.clone()).or_insert(&edge.callee);
1148
1149        if caller_key == callee_key {
1150            self_recursive.insert(caller_key);
1151            continue;
1152        }
1153
1154        outgoing.entry(caller_key.clone()).or_default().push(edge);
1155        has_incoming.insert(callee_key);
1156        has_outgoing.insert(caller_key);
1157    }
1158
1159    for children in outgoing.values_mut() {
1160        children.sort_by_key(|e| e.call_site_line.unwrap_or(u32::MAX));
1161    }
1162
1163    let mut roots: Vec<String> = Vec::new();
1164    for key in has_outgoing.iter() {
1165        if !has_incoming.contains(key) {
1166            roots.push(key.clone());
1167        }
1168    }
1169    for key in self_recursive.iter() {
1170        if !has_incoming.contains(key) && !has_outgoing.contains(key) {
1171            roots.push(key.clone());
1172        }
1173    }
1174
1175    fn count_reachable(
1176        key: &str,
1177        outgoing: &HashMap<String, Vec<&CallGraphEdge>>,
1178        seen: &mut HashSet<String>,
1179    ) -> usize {
1180        if !seen.insert(key.to_string()) {
1181            return 0;
1182        }
1183        let mut count = 1;
1184        let node_key = |s: &CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1185        if let Some(children) = outgoing.get(key) {
1186            for edge in children {
1187                count += count_reachable(&node_key(&edge.callee), outgoing, seen);
1188            }
1189        }
1190        count
1191    }
1192
1193    roots.sort_by(|a, b| {
1194        let size_a = count_reachable(a, &outgoing, &mut HashSet::new());
1195        let size_b = count_reachable(b, &outgoing, &mut HashSet::new());
1196        size_b.cmp(&size_a).then_with(|| a.cmp(b))
1197    });
1198
1199    let mut roots_by_lang: Vec<(String, Vec<String>)> = Vec::new();
1200    let mut lang_map: HashMap<String, Vec<String>> = HashMap::new();
1201    for root_key in roots {
1202        let lang = node_map
1203            .get(root_key.as_str())
1204            .map(|n| language_from_path(&n.path))
1205            .unwrap_or_else(|| "Unknown".to_string());
1206        lang_map.entry(lang).or_default().push(root_key);
1207    }
1208    for (lang, lang_roots) in lang_map {
1209        roots_by_lang.push((lang, lang_roots));
1210    }
1211    roots_by_lang.sort_by(|a, b| {
1212        let size_a: usize =
1213            a.1.iter()
1214                .map(|r| count_reachable(r, &outgoing, &mut HashSet::new()))
1215                .sum();
1216        let size_b: usize =
1217            b.1.iter()
1218                .map(|r| count_reachable(r, &outgoing, &mut HashSet::new()))
1219                .sum();
1220        size_b.cmp(&size_a).then_with(|| a.0.cmp(&b.0))
1221    });
1222
1223    let mut visited: HashSet<String> = HashSet::new();
1224    let mut lines: Vec<String> = Vec::new();
1225    let show_lang_headers = roots_by_lang.len() > 1;
1226
1227    for (lang, lang_roots) in &roots_by_lang {
1228        if show_lang_headers {
1229            if !lines.is_empty() {
1230                lines.push(String::new());
1231            }
1232            lines.push(lang.clone());
1233            lines.push("═".repeat(lang.len()));
1234            lines.push(String::new());
1235        }
1236
1237        for root_key in lang_roots {
1238            let node = match node_map.get(root_key.as_str()) {
1239                Some(n) => n,
1240                None => continue,
1241            };
1242
1243            let mut root_line = format_graph_node_label(node, true);
1244            let has_children = outgoing.contains_key(root_key.as_str());
1245            let is_self_recursive = self_recursive.contains(root_key.as_str());
1246
1247            if is_self_recursive && !has_children {
1248                root_line.push_str(" ↻");
1249                lines.push(root_line);
1250                lines.push(String::new());
1251                visited.insert(root_key.clone());
1252                continue;
1253            }
1254
1255            if is_self_recursive {
1256                root_line.push_str(" ↻");
1257            }
1258
1259            lines.push(root_line);
1260            visited.insert(root_key.clone());
1261
1262            if let Some(children) = outgoing.get(root_key.as_str()) {
1263                render_graph_tree(
1264                    children,
1265                    &outgoing,
1266                    &self_recursive,
1267                    &mut visited,
1268                    &mut lines,
1269                    "",
1270                );
1271            }
1272
1273            lines.push(String::new());
1274        }
1275    }
1276
1277    // Leaf-only nodes (have incoming but no outgoing, not self-recursive) are
1278    // already shown as leaves in the trees above, so skip them.
1279    // But if include_orphans, show nodes with no edges at all.
1280    if include_orphans {
1281        for node in &result.nodes {
1282            let key = node_key(node);
1283            if !has_incoming.contains(&key)
1284                && !has_outgoing.contains(&key)
1285                && !self_recursive.contains(&key)
1286            {
1287                lines.push(format!("{} (orphan)", format_graph_node_label(node, true)));
1288                lines.push(String::new());
1289            }
1290        }
1291    }
1292
1293    lines.join("\n")
1294}
1295
1296fn format_graph_node_label(node: &leta_types::CallGraphSymbol, show_path: bool) -> String {
1297    let mut parts = Vec::new();
1298    if show_path {
1299        parts.push(format!("{}:{}", node.path, node.line));
1300    }
1301    parts.push(format!("[{}]", node.kind));
1302    parts.push(node.name.clone());
1303    if let Some(detail) = &node.detail {
1304        if !detail.is_empty() && detail != "()" {
1305            let oneline: String = detail.split_whitespace().collect::<Vec<_>>().join(" ");
1306            let trimmed = if show_path {
1307                oneline.split(" • ").next().unwrap_or(&oneline)
1308            } else {
1309                &oneline
1310            };
1311            parts.push(format!("({})", trimmed));
1312        }
1313    }
1314    parts.join(" ")
1315}
1316
1317fn render_graph_tree(
1318    children: &[&leta_types::CallGraphEdge],
1319    outgoing: &HashMap<String, Vec<&leta_types::CallGraphEdge>>,
1320    self_recursive: &HashSet<String>,
1321    visited: &mut HashSet<String>,
1322    lines: &mut Vec<String>,
1323    prefix: &str,
1324) {
1325    let node_key = |s: &leta_types::CallGraphSymbol| format!("{}:{}:{}", s.path, s.line, s.name);
1326
1327    for (i, edge) in children.iter().enumerate() {
1328        let callee = &edge.callee;
1329        let in_workspace = edge.in_workspace;
1330        let is_last = i == children.len() - 1;
1331        let connector = if is_last { "└── " } else { "├── " };
1332        let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
1333
1334        let key = node_key(callee);
1335        let label = format_graph_node_label(callee, in_workspace);
1336        let is_self_recursive = self_recursive.contains(&key);
1337
1338        if !visited.insert(key.clone()) {
1339            let mut line = format!("{}{}{}", prefix, connector, label);
1340            if is_self_recursive {
1341                line.push_str(" ↻");
1342            }
1343            if outgoing.contains_key(key.as_str()) {
1344                line.push_str(" ↑");
1345            }
1346            lines.push(line);
1347            continue;
1348        }
1349
1350        let grandchildren = outgoing.get(key.as_str());
1351        let mut line = format!("{}{}{}", prefix, connector, label);
1352        if is_self_recursive {
1353            line.push_str(" ↻");
1354        }
1355        lines.push(line);
1356
1357        if let Some(grandchildren) = grandchildren {
1358            render_graph_tree(
1359                grandchildren,
1360                outgoing,
1361                self_recursive,
1362                visited,
1363                lines,
1364                &child_prefix,
1365            );
1366        }
1367    }
1368}
1369
1370fn language_from_path(path: &str) -> String {
1371    let ext = path.rsplit('.').next().unwrap_or("");
1372    match ext {
1373        "py" => "Python",
1374        "rs" => "Rust",
1375        "go" => "Go",
1376        "ts" | "tsx" => "TypeScript",
1377        "js" | "jsx" => "JavaScript",
1378        "java" => "Java",
1379        "rb" => "Ruby",
1380        "cpp" | "cc" | "cxx" | "c" | "h" | "hpp" => "C/C++",
1381        "php" => "PHP",
1382        "lua" => "Lua",
1383        "ml" | "mli" => "OCaml",
1384        "zig" => "Zig",
1385        _ => ext,
1386    }
1387    .to_string()
1388}