cs/output/
formatter.rs

1use crate::trace::{CallNode, CallTree, TraceDirection};
2use crate::tree::{NodeType, ReferenceTree, TreeNode};
3use crate::SearchResult;
4use colored::*;
5
6/// Formatter for rendering reference trees as text
7pub struct TreeFormatter {
8    max_width: usize,
9}
10
11impl TreeFormatter {
12    /// Create a new TreeFormatter with default width (80 columns)
13    pub fn new() -> Self {
14        // Force color output even when not in a TTY
15        colored::control::set_override(true);
16        Self { max_width: 80 }
17    }
18
19    /// Create a TreeFormatter with custom width
20    pub fn with_width(max_width: usize) -> Self {
21        Self { max_width }
22    }
23
24    /// Format a search result with clear sections
25    pub fn format_result(&self, result: &SearchResult) -> String {
26        let mut output = String::new();
27
28        // Section 1: Translation Files
29        if !result.translation_entries.is_empty() {
30            output.push_str(&format!("{}\n", "=== Translation Files ===".bold()));
31            for entry in &result.translation_entries {
32                output.push_str(&format!(
33                    "{}:{}:{}: {}\n",
34                    entry.file.display(),
35                    entry.line,
36                    entry.key.yellow().bold(),
37                    format!("\"{}\"", entry.value).green().bold()
38                ));
39            }
40            output.push('\n');
41        }
42
43        // Section 2: Code References
44        if !result.code_references.is_empty() {
45            output.push_str(&format!("{}\n", "=== Code References ===".bold()));
46            for code_ref in &result.code_references {
47                // Highlight the key in the context
48                let highlighted_context =
49                    self.highlight_key_in_context(&code_ref.context, &code_ref.key_path);
50                output.push_str(&format!(
51                    "{}:{}:{}\n",
52                    code_ref.file.display(),
53                    code_ref.line,
54                    highlighted_context
55                ));
56            }
57        }
58
59        output
60    }
61
62    /// Highlight the i18n key in the code context
63    fn highlight_key_in_context(&self, context: &str, key: &str) -> String {
64        // Make the key bold in the context
65        context.replace(key, &key.bold().to_string())
66    }
67
68    /// Format a reference tree as a string (legacy tree format)
69    pub fn format(&self, tree: &ReferenceTree) -> String {
70        let mut output = String::new();
71        self.format_node(&tree.root, &mut output, "", true, true);
72        output
73    }
74
75    pub fn format_trace_tree(&self, tree: &CallTree, direction: TraceDirection) -> String {
76        match direction {
77            TraceDirection::Forward => self.format_forward_tree(tree),
78            TraceDirection::Backward => self.format_backward_tree(tree),
79        }
80    }
81
82    fn format_forward_tree(&self, tree: &CallTree) -> String {
83        let mut output = String::new();
84        Self::format_call_node(&tree.root, &mut output, "", true, true);
85        output
86    }
87
88    fn format_backward_tree(&self, tree: &CallTree) -> String {
89        let mut output = String::new();
90        // For backward trace, we want to show chains like: caller -> callee -> target
91        // But the tree structure is target <- callee <- caller
92        // So we need to traverse from leaves to root, or just print the tree inverted.
93        // The requirement says: "Formats backward trace as chains (callers -> function)"
94        // Example: blah1 -> foo1 -> bar
95
96        // Let's traverse the tree and collect paths from leaves to root.
97        // Since the tree is built with target as root and callers as children,
98        // a path from a leaf to root represents a call chain: leaf calls ... calls root.
99
100        let mut paths = Vec::new();
101        Self::collect_backward_paths(&tree.root, vec![], &mut paths);
102
103        for path in paths {
104            // path is [leaf, ..., root]
105            // We want to print: leaf -> ... -> root
106            // But wait, the path collected by collect_backward_paths is [root, ..., leaf] because we push node then recurse?
107            // Let's check collect_backward_paths.
108            // current_path.push(node); recurse(child, current_path.clone())
109            // So yes, current_path is [root, child, ..., leaf].
110            // Root is the target. Leaf is the furthest caller.
111            // So path is [target, caller, caller_of_caller].
112            // We want: caller_of_caller -> caller -> target.
113            // So we need to reverse the path.
114
115            let mut display_path = path.clone();
116            display_path.reverse();
117
118            let mut chain = display_path
119                .iter()
120                .map(|node| {
121                    format!(
122                        "{} ({}:{})",
123                        node.def.name.bold(),
124                        node.def.file.display(),
125                        node.def.line
126                    )
127                })
128                .collect::<Vec<_>>()
129                .join(" -> ");
130
131            // Check if the leaf (first in display_path) was truncated
132            if let Some(first) = display_path.first() {
133                if first.truncated {
134                    chain = format!("{} -> {}", "[depth limit reached]".red(), chain);
135                }
136            }
137
138            output.push_str(&chain);
139            output.push('\n');
140        }
141
142        if output.is_empty() {
143            // If no callers found, just print the root
144            output.push_str(&format!(
145                "{} (No incoming calls found)\n",
146                tree.root.def.name
147            ));
148        }
149
150        output
151    }
152
153    fn collect_backward_paths<'a>(
154        node: &'a CallNode,
155        mut current_path: Vec<&'a CallNode>,
156        paths: &mut Vec<Vec<&'a CallNode>>,
157    ) {
158        current_path.push(node);
159
160        if node.children.is_empty() {
161            // Leaf node (a caller that is not called by anyone found/searched)
162            // or depth limit reached.
163            // If truncated, we should indicate it.
164            if node.truncated {
165                // If truncated, it means there are more callers but we stopped.
166                // We can append a special marker or just include the path.
167                // Let's just include the path for now.
168            }
169            paths.push(current_path);
170        } else {
171            for child in &node.children {
172                Self::collect_backward_paths(child, current_path.clone(), paths);
173            }
174        }
175    }
176
177    fn format_call_node(
178        node: &CallNode,
179        output: &mut String,
180        prefix: &str,
181        is_last: bool,
182        is_root: bool,
183    ) {
184        if !is_root {
185            output.push_str(prefix);
186            output.push_str(if is_last { "└─> " } else { "├─> " });
187        }
188
189        let content = format!(
190            "{} ({}:{})",
191            node.def.name.bold(),
192            node.def.file.display(),
193            node.def.line
194        );
195        output.push_str(&content);
196
197        if node.truncated {
198            output.push_str(&" [depth limit reached]".red().to_string());
199        }
200
201        output.push('\n');
202
203        let child_count = node.children.len();
204        for (i, child) in node.children.iter().enumerate() {
205            let is_last_child = i == child_count - 1;
206            let child_prefix = if is_root {
207                String::new()
208            } else {
209                format!("{}{}   ", prefix, if is_last { " " } else { "│" })
210            };
211            Self::format_call_node(child, output, &child_prefix, is_last_child, false);
212        }
213    }
214
215    /// Format a single node and its children
216    fn format_node(
217        &self,
218        node: &TreeNode,
219        output: &mut String,
220        prefix: &str,
221        is_last: bool,
222        is_root: bool,
223    ) {
224        // Format the current node
225        if !is_root {
226            output.push_str(prefix);
227            output.push_str(if is_last { "└─> " } else { "├─> " });
228        }
229
230        // Add node content
231        let content = self.format_content(node);
232        output.push_str(&content);
233
234        // Add location if present
235        if let Some(location) = &node.location {
236            let location_str = format!(" ({}:{})", location.file.display(), location.line);
237            output.push_str(&location_str);
238        }
239
240        output.push('\n');
241
242        // Format children
243        let child_count = node.children.len();
244        for (i, child) in node.children.iter().enumerate() {
245            let is_last_child = i == child_count - 1;
246            let child_prefix = if is_root {
247                String::new()
248            } else {
249                format!("{}{}   ", prefix, if is_last { " " } else { "│" })
250            };
251
252            self.format_node(child, output, &child_prefix, is_last_child, false);
253        }
254    }
255
256    /// Format node content based on node type
257    fn format_content(&self, node: &TreeNode) -> String {
258        match node.node_type {
259            NodeType::Root => {
260                format!("'{}' (search query)", node.content)
261            }
262            NodeType::Translation => {
263                // Truncate if too long
264                self.truncate(&node.content, self.max_width - 20)
265            }
266            NodeType::KeyPath => {
267                format!("Key: {}", node.content)
268            }
269            NodeType::CodeRef => {
270                // Truncate code context
271                let truncated = self.truncate(node.content.trim(), self.max_width - 30);
272
273                // Highlight if metadata is present
274                let display_content = if let Some(key) = &node.metadata {
275                    self.highlight_key_in_context(&truncated, key)
276                } else {
277                    truncated
278                };
279
280                format!("Code: {}", display_content)
281            }
282        }
283    }
284
285    /// Truncate a string to fit within max length (safe for unicode)
286    fn truncate(&self, s: &str, max_len: usize) -> String {
287        if s.chars().count() <= max_len {
288            s.to_string()
289        } else {
290            let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
291            format!("{}...", truncated)
292        }
293    }
294}
295
296impl Default for TreeFormatter {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::tree::{Location, TreeNode};
306    use std::path::PathBuf;
307
308    #[test]
309    fn test_formatter_creation() {
310        let formatter = TreeFormatter::new();
311        assert_eq!(formatter.max_width, 80);
312    }
313
314    #[test]
315    fn test_formatter_with_custom_width() {
316        let formatter = TreeFormatter::with_width(120);
317        assert_eq!(formatter.max_width, 120);
318    }
319
320    #[test]
321    fn test_format_empty_tree() {
322        let tree = ReferenceTree::with_search_text("test".to_string());
323        let formatter = TreeFormatter::new();
324        let output = formatter.format(&tree);
325
326        assert!(output.contains("'test'"));
327        assert!(output.contains("search query"));
328    }
329
330    #[test]
331    fn test_format_tree_with_translation() {
332        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
333        let translation = TreeNode::with_location(
334            NodeType::Translation,
335            "invoice.labels.add_new: 'add new'".to_string(),
336            Location::new(PathBuf::from("en.yml"), 4),
337        );
338        root.add_child(translation);
339
340        let tree = ReferenceTree::new(root);
341        let formatter = TreeFormatter::new();
342        let output = formatter.format(&tree);
343
344        assert!(output.contains("'add new'"));
345        assert!(output.contains("invoice.labels.add_new"));
346        assert!(output.contains("en.yml:4"));
347        assert!(output.contains("└─>") || output.contains("├─>"));
348    }
349
350    #[test]
351    fn test_format_complete_tree() {
352        let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
353
354        let mut translation = TreeNode::with_location(
355            NodeType::Translation,
356            "invoice.labels.add_new: 'add new'".to_string(),
357            Location::new(PathBuf::from("en.yml"), 4),
358        );
359
360        let mut key_path = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
361
362        let code_ref = TreeNode::with_location(
363            NodeType::CodeRef,
364            "I18n.t('invoice.labels.add_new')".to_string(),
365            Location::new(PathBuf::from("invoices.ts"), 14),
366        );
367
368        key_path.add_child(code_ref);
369        translation.add_child(key_path);
370        root.add_child(translation);
371
372        let tree = ReferenceTree::new(root);
373        let formatter = TreeFormatter::new();
374        let output = formatter.format(&tree);
375
376        // Verify all parts are present
377        assert!(output.contains("'add new'"));
378        assert!(output.contains("invoice.labels.add_new"));
379        assert!(output.contains("Key:"));
380        assert!(output.contains("Code:"));
381        assert!(output.contains("I18n.t"));
382        assert!(output.contains("en.yml:4"));
383        assert!(output.contains("invoices.ts:14"));
384    }
385
386    #[test]
387    fn test_format_multiple_children() {
388        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
389
390        let child1 = TreeNode::with_location(
391            NodeType::Translation,
392            "key1: 'value1'".to_string(),
393            Location::new(PathBuf::from("file1.yml"), 1),
394        );
395
396        let child2 = TreeNode::with_location(
397            NodeType::Translation,
398            "key2: 'value2'".to_string(),
399            Location::new(PathBuf::from("file2.yml"), 2),
400        );
401
402        root.add_child(child1);
403        root.add_child(child2);
404
405        let tree = ReferenceTree::new(root);
406        let formatter = TreeFormatter::new();
407        let output = formatter.format(&tree);
408
409        // Should have both children
410        assert!(output.contains("key1"));
411        assert!(output.contains("key2"));
412        assert!(output.contains("file1.yml:1"));
413        assert!(output.contains("file2.yml:2"));
414
415        // Should have proper tree connectors
416        assert!(output.contains("├─>"));
417        assert!(output.contains("└─>"));
418    }
419
420    #[test]
421    fn test_truncate_long_content() {
422        let formatter = TreeFormatter::with_width(50);
423        let long_string = "a".repeat(100);
424        let truncated = formatter.truncate(&long_string, 20);
425
426        assert!(truncated.len() <= 20);
427        assert!(truncated.ends_with("..."));
428    }
429
430    #[test]
431    fn test_truncate_short_content() {
432        let formatter = TreeFormatter::new();
433        let short_string = "short";
434        let result = formatter.truncate(short_string, 20);
435
436        assert_eq!(result, "short");
437    }
438
439    #[test]
440    fn test_format_content_root() {
441        let formatter = TreeFormatter::new();
442        let node = TreeNode::new(NodeType::Root, "test query".to_string());
443        let content = formatter.format_content(&node);
444
445        assert!(content.contains("test query"));
446        assert!(content.contains("search query"));
447    }
448
449    #[test]
450    fn test_format_content_key_path() {
451        let formatter = TreeFormatter::new();
452        let node = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
453        let content = formatter.format_content(&node);
454
455        assert!(content.contains("Key:"));
456        assert!(content.contains("invoice.labels.add_new"));
457    }
458
459    #[test]
460    fn test_format_content_code_ref() {
461        let formatter = TreeFormatter::new();
462        let node = TreeNode::new(
463            NodeType::CodeRef,
464            "  I18n.t('invoice.labels.add_new')  ".to_string(),
465        );
466        let content = formatter.format_content(&node);
467
468        assert!(content.contains("Code:"));
469        assert!(content.contains("I18n.t"));
470        // Should trim whitespace
471        assert!(!content.starts_with("  "));
472    }
473
474    #[test]
475    fn test_format_deep_nesting() {
476        let mut root = TreeNode::new(NodeType::Root, "test".to_string());
477        let mut level1 = TreeNode::new(NodeType::Translation, "level1".to_string());
478        let mut level2 = TreeNode::new(NodeType::KeyPath, "level2".to_string());
479        let level3 = TreeNode::new(NodeType::CodeRef, "level3".to_string());
480
481        level2.add_child(level3);
482        level1.add_child(level2);
483        root.add_child(level1);
484
485        let tree = ReferenceTree::new(root);
486        let formatter = TreeFormatter::new();
487        let output = formatter.format(&tree);
488
489        // Should have proper indentation
490        let lines: Vec<&str> = output.lines().collect();
491        assert!(lines.len() >= 4);
492
493        // Check that deeper levels have more indentation
494        assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
495    }
496}