1use crate::trace::{CallNode, CallTree, TraceDirection};
2use crate::tree::{NodeType, ReferenceTree, TreeNode};
3use crate::SearchResult;
4use colored::*;
5use regex::RegexBuilder;
6
7pub struct TreeFormatter {
9 max_width: usize,
10}
11
12impl TreeFormatter {
13 pub fn new() -> Self {
15 Self { max_width: 80 }
18 }
19
20 pub fn with_width(max_width: usize) -> Self {
22 Self { max_width }
23 }
24
25 pub fn format_result(&self, result: &SearchResult) -> String {
27 let mut output = String::new();
28
29 if !result.translation_entries.is_empty() {
31 output.push_str(&format!("{}\n", "=== Translation Files ===".bold()));
32 for entry in &result.translation_entries {
33 output.push_str(&format!(
34 "{}:{}:{}: {}\n",
35 entry.file.display(),
36 entry.line,
37 entry.key.yellow().bold(),
38 format!("\"{}\"", entry.value).green().bold()
39 ));
40 }
41 output.push('\n');
42 }
43
44 if !result.code_references.is_empty() {
46 output.push_str(&format!("{}\n", "=== Code References ===".bold()));
47 for code_ref in &result.code_references {
48 let highlighted_context =
50 self.highlight_key_in_context(&code_ref.context, &code_ref.key_path);
51 output.push_str(&format!(
52 "{}:{}:{}\n",
53 code_ref.file.display(),
54 code_ref.line,
55 highlighted_context
56 ));
57 }
58 }
59
60 output
61 }
62
63 fn highlight_key_in_context(&self, context: &str, key: &str) -> String {
65 let escaped_key = regex::escape(key);
67
68 let re = match RegexBuilder::new(&escaped_key)
70 .case_insensitive(true)
71 .build()
72 {
73 Ok(r) => r,
74 Err(_) => return context.to_string(), };
76
77 let result = re.replace_all(context, |caps: ®ex::Captures| caps[0].bold().to_string());
79
80 result.to_string()
81 }
82
83 pub fn format(&self, tree: &ReferenceTree) -> String {
85 let mut output = String::new();
86 self.format_node(&tree.root, &mut output, "", true, true);
87 output
88 }
89
90 pub fn format_trace_tree(&self, tree: &CallTree, direction: TraceDirection) -> String {
91 match direction {
92 TraceDirection::Forward => self.format_forward_tree(tree),
93 TraceDirection::Backward => self.format_backward_tree(tree),
94 }
95 }
96
97 fn format_forward_tree(&self, tree: &CallTree) -> String {
98 let mut output = String::new();
99 Self::format_call_node(&tree.root, &mut output, "", true, true);
100 output
101 }
102
103 fn format_backward_tree(&self, tree: &CallTree) -> String {
104 let mut output = String::new();
105 let mut paths = Vec::new();
116 Self::collect_backward_paths(&tree.root, vec![], &mut paths);
117
118 for path in paths {
119 let mut display_path = path.clone();
131 display_path.reverse();
132
133 let mut chain = display_path
134 .iter()
135 .map(|node| {
136 format!(
137 "{} ({}:{})",
138 node.def.name.bold(),
139 node.def.file.display(),
140 node.def.line
141 )
142 })
143 .collect::<Vec<_>>()
144 .join(" -> ");
145
146 if let Some(first) = display_path.first() {
148 if first.truncated {
149 chain = format!("{} -> {}", "[depth limit reached]".red(), chain);
150 }
151 }
152
153 output.push_str(&chain);
154 output.push('\n');
155 }
156
157 if output.is_empty() {
158 output.push_str(&format!(
160 "{} (No incoming calls found)\n",
161 tree.root.def.name
162 ));
163 }
164
165 output
166 }
167
168 fn collect_backward_paths<'a>(
169 node: &'a CallNode,
170 mut current_path: Vec<&'a CallNode>,
171 paths: &mut Vec<Vec<&'a CallNode>>,
172 ) {
173 current_path.push(node);
174
175 if node.children.is_empty() {
176 if node.truncated {
180 }
184 paths.push(current_path);
185 } else {
186 for child in &node.children {
187 Self::collect_backward_paths(child, current_path.clone(), paths);
188 }
189 }
190 }
191
192 fn format_call_node(
193 node: &CallNode,
194 output: &mut String,
195 prefix: &str,
196 is_last: bool,
197 is_root: bool,
198 ) {
199 if !is_root {
200 output.push_str(prefix);
201 output.push_str(if is_last { "└─> " } else { "├─> " });
202 }
203
204 let content = format!(
205 "{} ({}:{})",
206 node.def.name.bold(),
207 node.def.file.display(),
208 node.def.line
209 );
210 output.push_str(&content);
211
212 if node.truncated {
213 output.push_str(&" [depth limit reached]".red().to_string());
214 }
215
216 output.push('\n');
217
218 let child_count = node.children.len();
219 for (i, child) in node.children.iter().enumerate() {
220 let is_last_child = i == child_count - 1;
221 let child_prefix = if is_root {
222 String::new()
223 } else {
224 format!("{}{} ", prefix, if is_last { " " } else { "│" })
225 };
226 Self::format_call_node(child, output, &child_prefix, is_last_child, false);
227 }
228 }
229
230 fn format_node(
232 &self,
233 node: &TreeNode,
234 output: &mut String,
235 prefix: &str,
236 is_last: bool,
237 is_root: bool,
238 ) {
239 if !is_root {
241 output.push_str(prefix);
242 output.push_str(if is_last { "└─> " } else { "├─> " });
243 }
244
245 let content = self.format_content(node);
247 output.push_str(&content);
248
249 if let Some(location) = &node.location {
251 let location_str = format!(" ({}:{})", location.file.display(), location.line);
252 output.push_str(&location_str);
253 }
254
255 output.push('\n');
256
257 let child_count = node.children.len();
259 for (i, child) in node.children.iter().enumerate() {
260 let is_last_child = i == child_count - 1;
261 let child_prefix = if is_root {
262 String::new()
263 } else {
264 format!("{}{} ", prefix, if is_last { " " } else { "│" })
265 };
266
267 self.format_node(child, output, &child_prefix, is_last_child, false);
268 }
269 }
270
271 fn format_content(&self, node: &TreeNode) -> String {
273 match node.node_type {
274 NodeType::Root => {
275 format!("'{}' (search query)", node.content)
276 }
277 NodeType::Translation => {
278 let key = &node.content;
279 let value = node.metadata.as_deref().unwrap_or("");
280
281 let truncated_value = self.truncate(value, self.max_width - key.len() - 10);
283
284 format!("{}: '{}'", key.yellow().bold(), truncated_value.green())
285 }
286 NodeType::KeyPath => {
287 format!("Key: {}", node.content)
288 }
289 NodeType::CodeRef => {
290 let truncated = self.truncate(node.content.trim(), self.max_width - 30);
292
293 let display_content = if let Some(key) = &node.metadata {
295 self.highlight_key_in_context(&truncated, key)
296 } else {
297 truncated
298 };
299
300 format!("Code: {}", display_content)
301 }
302 }
303 }
304
305 fn truncate(&self, s: &str, max_len: usize) -> String {
307 if s.chars().count() <= max_len {
308 s.to_string()
309 } else {
310 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
311 format!("{}...", truncated)
312 }
313 }
314}
315
316impl Default for TreeFormatter {
317 fn default() -> Self {
318 Self::new()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::tree::{Location, TreeNode};
326 use std::path::PathBuf;
327
328 #[test]
329 fn test_formatter_creation() {
330 let formatter = TreeFormatter::new();
331 assert_eq!(formatter.max_width, 80);
332 }
333
334 #[test]
335 fn test_formatter_with_custom_width() {
336 let formatter = TreeFormatter::with_width(120);
337 assert_eq!(formatter.max_width, 120);
338 }
339
340 #[test]
341 fn test_format_empty_tree() {
342 let tree = ReferenceTree::with_search_text("test".to_string());
343 let formatter = TreeFormatter::new();
344 let output = formatter.format(&tree);
345
346 assert!(output.contains("'test'"));
347 assert!(output.contains("search query"));
348 }
349
350 #[test]
351 fn test_format_tree_with_translation() {
352 let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
353 let mut translation = TreeNode::with_location(
354 NodeType::Translation,
355 "invoice.labels.add_new".to_string(),
356 Location::new(PathBuf::from("en.yml"), 4),
357 );
358 translation.metadata = Some("add new".to_string());
359 root.add_child(translation);
360
361 let tree = ReferenceTree::new(root);
362 let formatter = TreeFormatter::new();
363 let output = formatter.format(&tree);
364
365 assert!(output.contains("'add new'"));
366 assert!(output.contains("invoice.labels.add_new"));
367 assert!(output.contains("en.yml:4"));
368 assert!(output.contains("└─>") || output.contains("├─>"));
369 }
370
371 #[test]
372 fn test_format_complete_tree() {
373 let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
374
375 let mut translation = TreeNode::with_location(
376 NodeType::Translation,
377 "invoice.labels.add_new".to_string(),
378 Location::new(PathBuf::from("en.yml"), 4),
379 );
380 translation.metadata = Some("add new".to_string());
381
382 let mut key_path = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
383
384 let code_ref = TreeNode::with_location(
385 NodeType::CodeRef,
386 "I18n.t('invoice.labels.add_new')".to_string(),
387 Location::new(PathBuf::from("invoices.ts"), 14),
388 );
389
390 key_path.add_child(code_ref);
391 translation.add_child(key_path);
392 root.add_child(translation);
393
394 let tree = ReferenceTree::new(root);
395 let formatter = TreeFormatter::new();
396 let output = formatter.format(&tree);
397
398 assert!(output.contains("'add new'"));
400 assert!(output.contains("invoice.labels.add_new"));
401 assert!(output.contains("Key:"));
402 assert!(output.contains("Code:"));
403 assert!(output.contains("I18n.t"));
404 assert!(output.contains("en.yml:4"));
405 assert!(output.contains("invoices.ts:14"));
406 }
407
408 #[test]
409 fn test_format_multiple_children() {
410 let mut root = TreeNode::new(NodeType::Root, "test".to_string());
411
412 let mut child1 = TreeNode::with_location(
413 NodeType::Translation,
414 "key1".to_string(),
415 Location::new(PathBuf::from("file1.yml"), 1),
416 );
417 child1.metadata = Some("value1".to_string());
418
419 let mut child2 = TreeNode::with_location(
420 NodeType::Translation,
421 "key2".to_string(),
422 Location::new(PathBuf::from("file2.yml"), 2),
423 );
424 child2.metadata = Some("value2".to_string());
425
426 root.add_child(child1);
427 root.add_child(child2);
428
429 let tree = ReferenceTree::new(root);
430 let formatter = TreeFormatter::new();
431 let output = formatter.format(&tree);
432
433 assert!(output.contains("key1"));
435 assert!(output.contains("key2"));
436 assert!(output.contains("file1.yml:1"));
437 assert!(output.contains("file2.yml:2"));
438
439 assert!(output.contains("├─>"));
441 assert!(output.contains("└─>"));
442 }
443
444 #[test]
445 fn test_truncate_long_content() {
446 let formatter = TreeFormatter::with_width(50);
447 let long_string = "a".repeat(100);
448 let truncated = formatter.truncate(&long_string, 20);
449
450 assert!(truncated.len() <= 20);
451 assert!(truncated.ends_with("..."));
452 }
453
454 #[test]
455 fn test_truncate_short_content() {
456 let formatter = TreeFormatter::new();
457 let short_string = "short";
458 let result = formatter.truncate(short_string, 20);
459
460 assert_eq!(result, "short");
461 }
462
463 #[test]
464 fn test_format_content_root() {
465 let formatter = TreeFormatter::new();
466 let node = TreeNode::new(NodeType::Root, "test query".to_string());
467 let content = formatter.format_content(&node);
468
469 assert!(content.contains("test query"));
470 assert!(content.contains("search query"));
471 }
472
473 #[test]
474 fn test_format_content_key_path() {
475 let formatter = TreeFormatter::new();
476 let node = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
477 let content = formatter.format_content(&node);
478
479 assert!(content.contains("Key:"));
480 assert!(content.contains("invoice.labels.add_new"));
481 }
482
483 #[test]
484 fn test_format_content_code_ref() {
485 let formatter = TreeFormatter::new();
486 let node = TreeNode::new(
487 NodeType::CodeRef,
488 " I18n.t('invoice.labels.add_new') ".to_string(),
489 );
490 let content = formatter.format_content(&node);
491
492 assert!(content.contains("Code:"));
493 assert!(content.contains("I18n.t"));
494 assert!(!content.starts_with(" "));
496 }
497
498 #[test]
499 fn test_format_deep_nesting() {
500 let mut root = TreeNode::new(NodeType::Root, "test".to_string());
501 let mut level1 = TreeNode::new(NodeType::Translation, "level1".to_string());
502 let mut level2 = TreeNode::new(NodeType::KeyPath, "level2".to_string());
503 let level3 = TreeNode::new(NodeType::CodeRef, "level3".to_string());
504
505 level2.add_child(level3);
506 level1.add_child(level2);
507 root.add_child(level1);
508
509 let tree = ReferenceTree::new(root);
510 let formatter = TreeFormatter::new();
511 let output = formatter.format(&tree);
512
513 let lines: Vec<&str> = output.lines().collect();
515 assert!(lines.len() >= 4);
516
517 assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
519 }
520
521 #[test]
522 fn test_highlight_case_insensitive_lowercase() {
523 colored::control::set_override(true); let formatter = TreeFormatter::new();
525 let context = "const value = pmfc.getData();";
526 let key = "PMFC";
527 let result = formatter.highlight_key_in_context(context, key);
528
529 assert!(result.contains("pmfc"));
531 assert_ne!(result, context);
534 }
535
536 #[test]
537 fn test_highlight_case_insensitive_uppercase() {
538 colored::control::set_override(true); let formatter = TreeFormatter::new();
540 let context = "const value = PMFC.getData();";
541 let key = "pmfc";
542 let result = formatter.highlight_key_in_context(context, key);
543
544 assert!(result.contains("PMFC"));
546 assert_ne!(result, context);
547 }
548
549 #[test]
550 fn test_highlight_case_insensitive_mixed() {
551 colored::control::set_override(true); let formatter = TreeFormatter::new();
553 let context = "const a = PmFc.get(); const b = pmfc.set();";
554 let key = "PMFC";
555 let result = formatter.highlight_key_in_context(context, key);
556
557 assert!(result.contains("PmFc"));
559 assert!(result.contains("pmfc"));
560 assert_ne!(result, context);
561 }
562
563 #[test]
564 fn test_highlight_with_special_regex_chars() {
565 colored::control::set_override(true); let formatter = TreeFormatter::new();
567 let context = "price: $19.99";
568 let key = "$19.99";
569 let result = formatter.highlight_key_in_context(context, key);
570
571 assert!(result.contains("$19.99"));
573 assert_ne!(result, context);
574 }
575
576 #[test]
577 fn test_highlight_exact_match_still_works() {
578 colored::control::set_override(true); let formatter = TreeFormatter::new();
580 let context = "I18n.t('invoice.labels.add_new')";
581 let key = "invoice.labels.add_new";
582 let result = formatter.highlight_key_in_context(context, key);
583
584 assert!(result.contains("invoice.labels.add_new"));
586 assert_ne!(result, context);
587 }
588}