Skip to main content

leta_output/
formatters.rs

1use std::collections::HashMap;
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 loc.name.is_some() && loc.kind.is_some() {
568            let mut parts = vec![
569                format!("{}:{}", loc.path, loc.line),
570                format!("[{}]", loc.kind.as_ref().unwrap()),
571                loc.name.clone().unwrap(),
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
677impl Default for FileTreePrinter {
678    fn default() -> Self {
679        Self::new()
680    }
681}
682
683pub fn format_symbol_line(sym: &SymbolInfo) -> String {
684    let location = format!("{}:{}", sym.path, sym.line);
685    let mut parts = vec![location, format!("[{}]", sym.kind), sym.name.clone()];
686    if let Some(detail) = &sym.detail {
687        if !detail.is_empty() && detail != "()" {
688            parts.push(format!("({})", detail));
689        }
690    }
691    if let Some(container) = &sym.container {
692        parts.push(format!("in {}", container));
693    }
694    let mut output = parts.join(" ");
695
696    if let Some(doc) = &sym.documentation {
697        for doc_line in doc.trim().lines() {
698            output.push_str(&format!("\n    {}", doc_line));
699        }
700    }
701    output
702}
703
704fn format_symbols(symbols: &[SymbolInfo]) -> String {
705    let mut lines = Vec::new();
706    for sym in symbols {
707        lines.push(format_symbol_line(sym));
708        if sym.documentation.is_some() {
709            lines.push(String::new());
710        }
711    }
712    lines.join("\n")
713}
714
715pub fn format_size(size: u64) -> String {
716    if size < 1024 {
717        format!("{}B", size)
718    } else if size < 1024 * 1024 {
719        format!("{:.1}KB", size as f64 / 1024.0)
720    } else {
721        format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
722    }
723}
724
725pub fn format_profiling(profiling: &ProfilingData) -> String {
726    let mut lines = Vec::new();
727
728    let cache = &profiling.cache;
729    let symbol_total = cache.symbol_hits + cache.symbol_misses;
730    let hover_total = cache.hover_hits + cache.hover_misses;
731
732    if symbol_total > 0 || hover_total > 0 {
733        lines.push("CACHE".to_string());
734        if symbol_total > 0 {
735            lines.push(format!(
736                "  symbols: {}/{} hits ({:.1}%)",
737                cache.symbol_hits,
738                symbol_total,
739                cache.symbol_hit_rate()
740            ));
741        }
742        if hover_total > 0 {
743            lines.push(format!(
744                "  hover: {}/{} hits ({:.1}%)",
745                cache.hover_hits,
746                hover_total,
747                cache.hover_hit_rate()
748            ));
749        }
750        lines.push(String::new());
751    }
752
753    if let Some(tree) = &profiling.span_tree {
754        lines.push(format!(
755            "{:<55} {:>7} {:>9} {:>9} {:>9}",
756            "Function", "Calls", "Avg", "P90", "Total"
757        ));
758        lines.push("-".repeat(90));
759
760        for root in &tree.roots {
761            format_span_node(root, &mut lines, 0, &tree.functions);
762        }
763
764        lines.push("-".repeat(90));
765        lines.push(format!(
766            "{:<55} {:>7} {:>9} {:>9} {:>9}",
767            "TOTAL",
768            "",
769            "",
770            "",
771            format_duration_us(tree.total_us)
772        ));
773    }
774
775    lines.join("\n")
776}
777
778fn get_func_stats<'a>(name: &str, functions: &'a [FunctionStats]) -> Option<&'a FunctionStats> {
779    functions.iter().find(|f| f.name == name)
780}
781
782fn format_span_node(
783    node: &SpanNode,
784    lines: &mut Vec<String>,
785    depth: usize,
786    functions: &[FunctionStats],
787) {
788    let indent = " ".repeat(depth);
789    let parallel_marker = if node.is_parallel { " ||" } else { "" };
790
791    let stats = get_func_stats(&node.name, functions);
792    let (calls, avg, p90) = if let Some(s) = stats {
793        (s.calls, s.avg_us, s.p90_us)
794    } else {
795        (
796            node.calls,
797            if node.calls > 0 {
798                node.total_us / node.calls as u64
799            } else {
800                0
801            },
802            0,
803        )
804    };
805
806    let name_with_indent = format!("{}{}{}", indent, node.name, parallel_marker);
807
808    lines.push(format!(
809        "{:<55} {:>7} {:>9} {:>9} {:>9}",
810        truncate_left(&name_with_indent, 55),
811        calls,
812        format_duration_us(avg),
813        format_duration_us(p90),
814        format_duration_us(node.total_us)
815    ));
816
817    // Show properties if any - extract timing info from properties
818    let mut property_time_ms = 0.0f64;
819    if !node.properties.is_empty() {
820        let props_indent = " ".repeat(depth + 1);
821
822        // Aggregate properties by key, summing numeric values for _ms keys
823        let mut aggregated: std::collections::HashMap<&str, (f64, u32)> =
824            std::collections::HashMap::new();
825        for (k, v) in &node.properties {
826            if k.ends_with("_ms") {
827                if let Ok(ms) = v.parse::<f64>() {
828                    property_time_ms += ms;
829                    let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
830                    entry.0 += ms;
831                    entry.1 += 1;
832                }
833            } else if let Ok(num) = v.parse::<f64>() {
834                let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
835                entry.0 += num;
836                entry.1 += 1;
837            } else {
838                // Non-numeric properties just count occurrences
839                let entry = aggregated.entry(k.as_str()).or_insert((0.0, 0));
840                entry.1 += 1;
841            }
842        }
843
844        // Format aggregated properties
845        let mut props_str: Vec<String> = aggregated
846            .iter()
847            .map(|(k, (sum, count))| {
848                if *count > 1 {
849                    if k.ends_with("_ms") {
850                        format!(
851                            "{}={:.1}ms total ({} calls)",
852                            k.trim_end_matches("_ms"),
853                            sum,
854                            count
855                        )
856                    } else {
857                        format!("{}={:.0} total", k, sum)
858                    }
859                } else if k.ends_with("_ms") {
860                    format!("{}={:.1}ms", k.trim_end_matches("_ms"), sum)
861                } else {
862                    format!("{}={:.0}", k, sum)
863                }
864            })
865            .collect();
866        props_str.sort();
867
868        lines.push(format!("{}  [{}]", props_indent, props_str.join(", ")));
869    }
870
871    for child in &node.children {
872        format_span_node(child, lines, depth + 1, functions);
873    }
874
875    // Only show unaccounted if:
876    // 1. There's significant unaccounted time (> 1ms)
877    // 2. There are children (otherwise all time is "self")
878    // 3. Properties don't already explain most of the time
879    let property_time_us = (property_time_ms * 1000.0) as u64;
880    let truly_unaccounted = node.self_us.saturating_sub(property_time_us);
881
882    if truly_unaccounted > 1000 && !node.children.is_empty() {
883        let unaccounted_name = format!("{}[unaccounted]", " ".repeat(depth + 1));
884        lines.push(format!(
885            "{:<55} {:>7} {:>9} {:>9} {:>9}",
886            unaccounted_name,
887            "",
888            "",
889            "",
890            format_duration_us(truly_unaccounted)
891        ));
892    }
893}
894
895fn truncate_left(s: &str, max_len: usize) -> String {
896    if s.len() <= max_len {
897        s.to_string()
898    } else {
899        format!("…{}", &s[s.len() - max_len + 1..])
900    }
901}
902
903enum TreeNode {
904    File(FileInfo),
905    Dir(HashMap<String, TreeNode>),
906    ExcludedDir,
907}
908
909fn build_tree(
910    files: &HashMap<String, FileInfo>,
911    excluded_dirs: &[String],
912) -> HashMap<String, TreeNode> {
913    let mut tree: HashMap<String, TreeNode> = HashMap::new();
914
915    for (rel_path, info) in files {
916        let parts: Vec<&str> = rel_path.split('/').collect();
917        let mut current = &mut tree;
918
919        for (i, part) in parts.iter().enumerate() {
920            if i == parts.len() - 1 {
921                current.insert(part.to_string(), TreeNode::File(info.clone()));
922            } else {
923                current = match current
924                    .entry(part.to_string())
925                    .or_insert_with(|| TreeNode::Dir(HashMap::new()))
926                {
927                    TreeNode::Dir(map) => map,
928                    _ => unreachable!(),
929                };
930            }
931        }
932    }
933
934    for excluded_path in excluded_dirs {
935        let parts: Vec<&str> = excluded_path.split('/').collect();
936        let mut current = &mut tree;
937
938        for (i, part) in parts.iter().enumerate() {
939            if i == parts.len() - 1 {
940                current
941                    .entry(part.to_string())
942                    .or_insert(TreeNode::ExcludedDir);
943            } else {
944                current = match current
945                    .entry(part.to_string())
946                    .or_insert_with(|| TreeNode::Dir(HashMap::new()))
947                {
948                    TreeNode::Dir(map) => map,
949                    _ => break,
950                };
951            }
952        }
953    }
954
955    tree
956}
957
958fn render_tree(node: &HashMap<String, TreeNode>, lines: &mut Vec<String>, indent: usize) {
959    let mut entries: Vec<_> = node.keys().collect();
960    entries.sort();
961
962    let prefix = "  ".repeat(indent);
963
964    for name in entries {
965        let child = node.get(name).unwrap();
966
967        match child {
968            TreeNode::File(info) => {
969                let info_str = format_file_info(info);
970                lines.push(format!("{}{} ({})", prefix, name, info_str));
971            }
972            TreeNode::Dir(children) => {
973                lines.push(format!("{}{}/", prefix, name));
974                render_tree(children, lines, indent + 1);
975            }
976            TreeNode::ExcludedDir => {
977                lines.push(format!("{}{} (excluded)", prefix, name));
978            }
979        }
980    }
981}
982
983fn format_file_info(info: &FileInfo) -> String {
984    format!("{}, {} lines", format_size(info.bytes), info.lines)
985}
986
987fn is_stdlib_path(path: &str) -> bool {
988    path.contains("/typeshed-fallback/stdlib/")
989        || path.contains("/typeshed/stdlib/")
990        || (path.contains("/libexec/src/") && !path.contains("/mod/"))
991        || (path.ends_with(".d.ts")
992            && path
993                .split('/')
994                .next_back()
995                .map(|f| f.starts_with("lib."))
996                .unwrap_or(false))
997        || path.contains("/rustlib/src/rust/library/")
998}
999
1000fn should_show_detail(detail: &Option<String>) -> bool {
1001    detail
1002        .as_ref()
1003        .map(|d| !d.is_empty() && d != "()")
1004        .unwrap_or(false)
1005}
1006
1007fn format_call_tree(node: &CallNode) -> String {
1008    let mut lines = Vec::new();
1009
1010    let mut parts: Vec<String> = Vec::new();
1011    if let Some(path) = &node.path {
1012        parts.push(format!("{}:{}", path, node.line.unwrap_or(0)));
1013    }
1014    if let Some(kind) = &node.kind {
1015        parts.push(format!("[{}]", kind));
1016    }
1017    parts.push(node.name.clone());
1018    if should_show_detail(&node.detail) {
1019        parts.push(format!("({})", node.detail.as_ref().unwrap()));
1020    }
1021    lines.push(parts.join(" "));
1022
1023    if let Some(calls) = &node.calls {
1024        lines.push(String::new());
1025        lines.push("Outgoing calls:".to_string());
1026        if !calls.is_empty() {
1027            render_calls_tree(calls, &mut lines, "  ", true);
1028        }
1029    } else if let Some(called_by) = &node.called_by {
1030        lines.push(String::new());
1031        lines.push("Incoming calls:".to_string());
1032        if !called_by.is_empty() {
1033            render_calls_tree(called_by, &mut lines, "  ", false);
1034        }
1035    }
1036
1037    lines.join("\n")
1038}
1039
1040fn render_calls_tree(items: &[CallNode], lines: &mut Vec<String>, prefix: &str, is_outgoing: bool) {
1041    for (i, item) in items.iter().enumerate() {
1042        let is_last = i == items.len() - 1;
1043        let connector = if is_last { "└── " } else { "├── " };
1044        let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
1045
1046        let path = item.path.as_deref().unwrap_or("");
1047        let line = item.line.unwrap_or(0);
1048
1049        let mut parts: Vec<String> = Vec::new();
1050        if is_stdlib_path(path) {
1051            if let Some(kind) = &item.kind {
1052                parts.push(format!("[{}]", kind));
1053            }
1054        } else {
1055            parts.push(format!("{}:{}", path, line));
1056            if let Some(kind) = &item.kind {
1057                parts.push(format!("[{}]", kind));
1058            }
1059        }
1060        parts.push(item.name.clone());
1061        if should_show_detail(&item.detail) {
1062            parts.push(format!("({})", item.detail.as_ref().unwrap()));
1063        }
1064        lines.push(format!("{}{}{}", prefix, connector, parts.join(" ")));
1065
1066        let children = if is_outgoing {
1067            &item.calls
1068        } else {
1069            &item.called_by
1070        };
1071        if let Some(children) = children {
1072            render_calls_tree(children, lines, &child_prefix, is_outgoing);
1073        }
1074    }
1075}
1076
1077fn format_call_path(path: &[CallNode]) -> String {
1078    if path.is_empty() {
1079        return "Empty path".to_string();
1080    }
1081
1082    let mut lines = vec!["Call path:".to_string()];
1083    for (i, item) in path.iter().enumerate() {
1084        let file_path = item.path.as_deref().unwrap_or("");
1085        let line = item.line.unwrap_or(0);
1086
1087        let mut parts = vec![format!("{}:{}", file_path, line)];
1088        if let Some(kind) = &item.kind {
1089            parts.push(format!("[{}]", kind));
1090        }
1091        parts.push(item.name.clone());
1092        if should_show_detail(&item.detail) {
1093            parts.push(format!("({})", item.detail.as_ref().unwrap()));
1094        }
1095
1096        let arrow = if i == 0 { "" } else { "  → " };
1097        lines.push(format!("{}{}", arrow, parts.join(" ")));
1098    }
1099
1100    lines.join("\n")
1101}