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