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(30);
316 let width = if available_width < 20 {
317 20
318 } else {
319 available_width
320 };
321 let truncated = self.truncate(node.content.trim(), width);
322
323 if let Some(key) = &node.metadata {
325 self.highlight_key_in_context(&truncated, key)
326 } else {
327 truncated
328 }
329 }
330 }
331 }
332
333 fn truncate(&self, s: &str, max_len: usize) -> String {
335 if s.chars().count() <= max_len {
336 s.to_string()
337 } else {
338 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
339 format!("{}...", truncated)
340 }
341 }
342}
343
344impl Default for TreeFormatter {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::tree::{Location, TreeNode};
354 use std::path::PathBuf;
355
356 #[test]
357 fn test_formatter_creation() {
358 let formatter = TreeFormatter::new();
359 assert_eq!(formatter.max_width, 80);
360 }
361
362 #[test]
363 fn test_formatter_with_custom_width() {
364 let formatter = TreeFormatter::with_width(120);
365 assert_eq!(formatter.max_width, 120);
366 }
367
368 #[test]
369 fn test_format_empty_tree() {
370 let tree = ReferenceTree::with_search_text("test".to_string());
371 let formatter = TreeFormatter::new();
372 let output = formatter.format(&tree);
373
374 assert!(output.contains("'test'"));
375 assert!(output.contains("search query"));
376 }
377
378 #[test]
379 fn test_format_tree_with_translation() {
380 let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
381 let mut translation = TreeNode::with_location(
382 NodeType::Translation,
383 "invoice.labels.add_new".to_string(),
384 Location::new(PathBuf::from("en.yml"), 4),
385 );
386 translation.metadata = Some("add new".to_string());
387 root.add_child(translation);
388
389 let tree = ReferenceTree::new(root);
390 let formatter = TreeFormatter::new();
391 let output = formatter.format(&tree);
392
393 assert!(output.contains("'add new'"));
394 assert!(output.contains("invoice.labels.add_new"));
395 assert!(output.contains("en.yml:4"));
396 assert!(output.contains("└─>") || output.contains("├─>"));
397 }
398
399 #[test]
400 fn test_format_complete_tree() {
401 let mut root = TreeNode::new(NodeType::Root, "add new".to_string());
402
403 let mut translation = TreeNode::with_location(
404 NodeType::Translation,
405 "invoice.labels.add_new".to_string(),
406 Location::new(PathBuf::from("en.yml"), 4),
407 );
408 translation.metadata = Some("add new".to_string());
409
410 let mut key_path = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
411
412 let code_ref = TreeNode::with_location(
413 NodeType::CodeRef,
414 "I18n.t('invoice.labels.add_new')".to_string(),
415 Location::new(PathBuf::from("invoices.ts"), 14),
416 );
417
418 key_path.add_child(code_ref);
419 translation.add_child(key_path);
420 root.add_child(translation);
421
422 let tree = ReferenceTree::new(root);
423 let formatter = TreeFormatter::new();
424 let output = formatter.format(&tree);
425
426 assert!(output.contains("'add new'"));
428 assert!(output.contains("invoice.labels.add_new"));
429 assert!(output.contains("Key:"));
430 assert!(output.contains("I18n.t"));
431 assert!(output.contains("en.yml:4"));
432 assert!(output.contains("invoices.ts:14"));
433 }
434
435 #[test]
436 fn test_format_multiple_children() {
437 let mut root = TreeNode::new(NodeType::Root, "test".to_string());
438
439 let mut child1 = TreeNode::with_location(
440 NodeType::Translation,
441 "key1".to_string(),
442 Location::new(PathBuf::from("file1.yml"), 1),
443 );
444 child1.metadata = Some("value1".to_string());
445
446 let mut child2 = TreeNode::with_location(
447 NodeType::Translation,
448 "key2".to_string(),
449 Location::new(PathBuf::from("file2.yml"), 2),
450 );
451 child2.metadata = Some("value2".to_string());
452
453 root.add_child(child1);
454 root.add_child(child2);
455
456 let tree = ReferenceTree::new(root);
457 let formatter = TreeFormatter::new();
458 let output = formatter.format(&tree);
459
460 assert!(output.contains("key1"));
462 assert!(output.contains("key2"));
463 assert!(output.contains("file1.yml:1"));
464 assert!(output.contains("file2.yml:2"));
465
466 assert!(output.contains("├─>"));
468 assert!(output.contains("└─>"));
469 }
470
471 #[test]
472 fn test_truncate_long_content() {
473 let formatter = TreeFormatter::with_width(50);
474 let long_string = "a".repeat(100);
475 let truncated = formatter.truncate(&long_string, 20);
476
477 assert!(truncated.len() <= 20);
478 assert!(truncated.ends_with("..."));
479 }
480
481 #[test]
482 fn test_truncate_short_content() {
483 let formatter = TreeFormatter::new();
484 let short_string = "short";
485 let result = formatter.truncate(short_string, 20);
486
487 assert_eq!(result, "short");
488 }
489
490 #[test]
491 fn test_format_content_root() {
492 let formatter = TreeFormatter::new();
493 let node = TreeNode::new(NodeType::Root, "test query".to_string());
494 let content = formatter.format_content(&node);
495
496 assert!(content.contains("test query"));
497 assert!(content.contains("search query"));
498 }
499
500 #[test]
501 fn test_format_content_key_path() {
502 let formatter = TreeFormatter::new();
503 let node = TreeNode::new(NodeType::KeyPath, "invoice.labels.add_new".to_string());
504 let content = formatter.format_content(&node);
505
506 assert!(content.contains("Key:"));
507 assert!(content.contains("invoice.labels.add_new"));
508 }
509
510 #[test]
511 fn test_format_content_code_ref() {
512 let formatter = TreeFormatter::new();
513 let node = TreeNode::new(
514 NodeType::CodeRef,
515 " I18n.t('invoice.labels.add_new') ".to_string(),
516 );
517 let content = formatter.format_content(&node);
518
519 assert!(content.contains("I18n.t"));
520 assert!(!content.starts_with(" "));
522 }
523
524 #[test]
525 fn test_format_deep_nesting() {
526 let mut root = TreeNode::new(NodeType::Root, "test".to_string());
527 let mut level1 = TreeNode::new(NodeType::Translation, "level1".to_string());
528 let mut level2 = TreeNode::new(NodeType::KeyPath, "level2".to_string());
529 let level3 = TreeNode::new(NodeType::CodeRef, "level3".to_string());
530
531 level2.add_child(level3);
532 level1.add_child(level2);
533 root.add_child(level1);
534
535 let tree = ReferenceTree::new(root);
536 let formatter = TreeFormatter::new();
537 let output = formatter.format(&tree);
538
539 let lines: Vec<&str> = output.lines().collect();
541 assert!(lines.len() >= 4);
542
543 assert!(lines[2].starts_with(' ') || lines[2].starts_with('│'));
545 }
546
547 #[test]
548 fn test_highlight_case_insensitive_lowercase() {
549 colored::control::set_override(true); let formatter = TreeFormatter::new();
551 let context = "const value = pmfc.getData();";
552 let key = "PMFC";
553 let result = formatter.highlight_key_in_context(context, key);
554
555 assert!(result.contains("pmfc"));
557 assert_ne!(result, context);
560 }
561
562 #[test]
563 fn test_highlight_case_insensitive_uppercase() {
564 colored::control::set_override(true); let formatter = TreeFormatter::new();
566 let context = "const value = PMFC.getData();";
567 let key = "pmfc";
568 let result = formatter.highlight_key_in_context(context, key);
569
570 assert!(result.contains("PMFC"));
572 assert_ne!(result, context);
573 }
574
575 #[test]
576 fn test_highlight_case_insensitive_mixed() {
577 colored::control::set_override(true); let formatter = TreeFormatter::new();
579 let context = "const a = PmFc.get(); const b = pmfc.set();";
580 let key = "PMFC";
581 let result = formatter.highlight_key_in_context(context, key);
582
583 assert!(result.contains("PmFc"));
585 assert!(result.contains("pmfc"));
586 assert_ne!(result, context);
587 }
588
589 #[test]
590 fn test_highlight_with_special_regex_chars() {
591 colored::control::set_override(true); let formatter = TreeFormatter::new();
593 let context = "price: $19.99";
594 let key = "$19.99";
595 let result = formatter.highlight_key_in_context(context, key);
596
597 assert!(result.contains("$19.99"));
599 assert_ne!(result, context);
600 }
601
602 #[test]
603 fn test_highlight_exact_match_still_works() {
604 colored::control::set_override(true); let formatter = TreeFormatter::new();
606 let context = "I18n.t('invoice.labels.add_new')";
607 let key = "invoice.labels.add_new";
608 let result = formatter.highlight_key_in_context(context, key);
609
610 assert!(result.contains("invoice.labels.add_new"));
612 assert_ne!(result, context);
613 }
614}