1use std::io::{self, Write};
13use std::path::Path;
14
15use colored::Colorize;
16use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
17use serde::Serialize;
18use tldr_core::util::{truncate_at_char_boundary, truncate_at_char_boundary_from_end};
19
20pub fn common_path_prefix(paths: &[&Path]) -> std::path::PathBuf {
24 if paths.is_empty() {
25 return std::path::PathBuf::new();
26 }
27 if paths.len() == 1 {
28 return paths[0].parent().unwrap_or(Path::new("")).to_path_buf();
29 }
30
31 let first = paths[0];
32 let components: Vec<_> = first.components().collect();
33 let mut prefix_len = components.len();
34
35 for path in &paths[1..] {
36 let other: Vec<_> = path.components().collect();
37 let mut match_len = 0;
38 for (a, b) in components.iter().zip(other.iter()) {
39 if a == b {
40 match_len += 1;
41 } else {
42 break;
43 }
44 }
45 prefix_len = prefix_len.min(match_len);
46 }
47
48 let mut result = std::path::PathBuf::new();
50 for comp in components.iter().take(prefix_len) {
51 result.push(comp);
52 }
53 result
54}
55
56pub fn strip_prefix_display(path: &Path, prefix: &Path) -> String {
59 if prefix.as_os_str().is_empty() {
60 return path.display().to_string();
61 }
62 match path.strip_prefix(prefix) {
63 Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
64 _ => path.display().to_string(),
65 }
66}
67
68#[derive(Debug, Clone, Copy, Default, clap::ValueEnum, PartialEq, Eq)]
70pub enum OutputFormat {
71 #[default]
73 Json,
74 Text,
76 Compact,
78 Sarif,
80 Dot,
82}
83
84impl OutputFormat {
85 pub fn name(&self) -> &'static str {
87 match self {
88 OutputFormat::Json => "json",
89 OutputFormat::Text => "text",
90 OutputFormat::Compact => "compact",
91 OutputFormat::Sarif => "sarif",
92 OutputFormat::Dot => "dot",
93 }
94 }
95}
96
97pub fn validate_format_for_command(cmd: &str, format: OutputFormat) -> Result<(), String> {
114 match format {
116 OutputFormat::Json | OutputFormat::Text | OutputFormat::Compact => return Ok(()),
117 OutputFormat::Sarif | OutputFormat::Dot => {}
118 }
119
120 const SARIF_SUPPORTED: &[&str] = &["vuln", "clones"];
125 const DOT_SUPPORTED: &[&str] = &[
130 "clones",
131 "deps",
132 "calls",
133 "impact",
134 "hubs",
135 "inheritance",
136 ];
137
138 match format {
139 OutputFormat::Sarif => {
140 if SARIF_SUPPORTED.contains(&cmd) {
141 Ok(())
142 } else {
143 Err(format!(
144 "--format sarif not supported by {cmd}. Use --format json. \
145 SARIF is only emitted by: {}.",
146 SARIF_SUPPORTED.join(", ")
147 ))
148 }
149 }
150 OutputFormat::Dot => {
151 if DOT_SUPPORTED.contains(&cmd) {
152 Ok(())
153 } else {
154 Err(format!(
155 "--format dot not supported by {cmd}. Use --format json. \
156 DOT is only emitted by: {}.",
157 DOT_SUPPORTED.join(", ")
158 ))
159 }
160 }
161 _ => Ok(()),
162 }
163}
164
165pub struct OutputWriter {
167 format: OutputFormat,
168 quiet: bool,
169}
170
171impl OutputWriter {
172 pub fn new(format: OutputFormat, quiet: bool) -> Self {
174 Self { format, quiet }
175 }
176
177 pub fn write<T: Serialize>(&self, value: &T) -> io::Result<()> {
179 let stdout = io::stdout();
180 let mut handle = stdout.lock();
181
182 match self.format {
183 OutputFormat::Json | OutputFormat::Sarif => {
184 serde_json::to_writer_pretty(&mut handle, value)?;
186 writeln!(handle)?;
187 }
188 OutputFormat::Compact => {
189 serde_json::to_writer(&mut handle, value)?;
190 writeln!(handle)?;
191 }
192 OutputFormat::Text | OutputFormat::Dot => {
193 serde_json::to_writer_pretty(&mut handle, value)?;
195 writeln!(handle)?;
196 }
197 }
198
199 Ok(())
200 }
201
202 pub fn write_text(&self, text: &str) -> io::Result<()> {
204 let stdout = io::stdout();
205 let mut handle = stdout.lock();
206 writeln!(handle, "{}", text)?;
207 Ok(())
208 }
209
210 pub fn progress(&self, message: &str) {
221 if self.quiet {
222 return;
223 }
224 if matches!(
225 self.format,
226 OutputFormat::Json | OutputFormat::Compact | OutputFormat::Sarif
227 ) {
228 return;
229 }
230 eprintln!("{}", message.dimmed());
231 }
232
233 pub fn is_text(&self) -> bool {
235 matches!(self.format, OutputFormat::Text)
236 }
237
238 #[allow(dead_code)]
240 pub fn is_json(&self) -> bool {
241 matches!(
242 self.format,
243 OutputFormat::Json | OutputFormat::Compact | OutputFormat::Sarif
244 )
245 }
246
247 pub fn is_dot(&self) -> bool {
249 matches!(self.format, OutputFormat::Dot)
250 }
251}
252
253pub fn format_file_tree_text(tree: &tldr_core::FileTree, indent: usize) -> String {
259 let mut output = String::new();
260 format_tree_node(tree, &mut output, indent, "");
261 output
262}
263
264fn format_tree_node(tree: &tldr_core::FileTree, output: &mut String, indent: usize, prefix: &str) {
265 let indent_str = " ".repeat(indent);
266 let icon_plain = match tree.node_type {
268 tldr_core::NodeType::Dir => "[D]".yellow().to_string(),
269 tldr_core::NodeType::File => "[F]".blue().to_string(),
270 };
271
272 output.push_str(&format!(
273 "{}{}{} {}\n",
274 prefix, indent_str, icon_plain, tree.name
275 ));
276
277 for (i, child) in tree.children.iter().enumerate() {
278 let is_last = i == tree.children.len() - 1;
279 let new_prefix = if is_last { "`-- " } else { "|-- " };
280 let cont_prefix = if is_last { " " } else { "| " };
281 format_tree_node(
282 child,
283 output,
284 0,
285 &format!("{}{}{}", prefix, cont_prefix, new_prefix),
286 );
287 }
288}
289
290pub fn format_structure_text(structure: &tldr_core::CodeStructure) -> String {
303 use std::collections::HashMap;
304
305 let mut output = String::new();
306
307 output.push_str(&format!(
308 "{} ({} files)\n",
309 structure.root.display().to_string().bold(),
310 structure.files.len()
311 ));
312 output.push_str(&format!(
316 "Language: {}\n\n",
317 match &structure.language {
318 Some(lang) => format!("{:?}", lang).cyan(),
319 None => "(none — no source files found)".to_string().cyan(),
320 }
321 ));
322
323 let prefix = &structure.root;
324
325 for file in &structure.files {
326 let rel = strip_prefix_display(&file.path, prefix);
327 output.push_str(&format!("{}\n", rel.green()));
328
329 let mut def_index: HashMap<&str, (u32, &str)> = HashMap::new();
332 for d in &file.definitions {
333 if d.kind == "function" || d.kind == "method" {
334 def_index.insert(d.name.as_str(), (d.line_start, d.signature.as_str()));
335 }
336 }
337
338 if !file.functions.is_empty() {
339 output.push_str(" Functions:\n");
340 for func in &file.functions {
341 if let Some((line, sig)) = def_index.get(func.as_str()) {
342 if !sig.is_empty() {
343 output.push_str(&format!(" - {} ({}:{})\n", sig, "L".dimmed(), line));
344 } else {
345 output.push_str(&format!(" - {} (L{})\n", func, line));
346 }
347 } else {
348 output.push_str(&format!(" - {}\n", func));
349 }
350 }
351 }
352
353 if !file.classes.is_empty() {
354 output.push_str(" Classes:\n");
361 for (i, class) in file.classes.iter().enumerate() {
362 output.push_str(&format!(" - {}\n", class.bold()));
363 if file.classes.len() == 1 && i == 0 && !file.method_infos.is_empty() {
364 for m in &file.method_infos {
365 if !m.signature.is_empty() {
366 output.push_str(&format!(
367 " . {} (L{})\n",
368 m.signature, m.line
369 ));
370 } else {
371 output.push_str(&format!(" . {} (L{})\n", m.name, m.line));
372 }
373 }
374 }
375 }
376
377 if file.classes.len() > 1 && !file.method_infos.is_empty() {
380 output.push_str(" Methods:\n");
381 for m in &file.method_infos {
382 if !m.signature.is_empty() {
383 output.push_str(&format!(" - {} (L{})\n", m.signature, m.line));
384 } else {
385 output.push_str(&format!(" - {} (L{})\n", m.name, m.line));
386 }
387 }
388 }
389 } else if !file.method_infos.is_empty() {
390 output.push_str(" Methods:\n");
392 for m in &file.method_infos {
393 if !m.signature.is_empty() {
394 output.push_str(&format!(" - {} (L{})\n", m.signature, m.line));
395 } else {
396 output.push_str(&format!(" - {} (L{})\n", m.name, m.line));
397 }
398 }
399 }
400
401 output.push('\n');
402 }
403
404 output
405}
406
407pub fn format_imports_text(imports: &[tldr_core::types::ImportInfo]) -> String {
418 use std::collections::BTreeMap;
419
420 let mut output = String::new();
421
422 if imports.is_empty() {
423 output.push_str("No imports found.\n");
424 return output;
425 }
426
427 let mut from_groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
429 let mut bare_imports: Vec<String> = Vec::new();
430
431 for imp in imports {
432 if imp.is_from && !imp.names.is_empty() {
433 let names = from_groups.entry(&imp.module).or_default();
434 for name in &imp.names {
435 names.push(name);
436 }
437 } else if let Some(alias) = &imp.alias {
438 bare_imports.push(format!("{} as {}", imp.module, alias));
439 } else {
440 bare_imports.push(imp.module.clone());
441 }
442 }
443
444 for (module, names) in &from_groups {
446 output.push_str(&format!(
447 "from {}: {}\n",
448 module.cyan(),
449 names.join(", ").green(),
450 ));
451 }
452
453 if !bare_imports.is_empty() {
455 if !from_groups.is_empty() {
456 output.push('\n');
457 }
458 output.push_str(&format!("import {}\n", bare_imports.join(", ").cyan()));
459 }
460
461 output
462}
463
464pub fn format_importers_text(report: &tldr_core::types::ImportersReport) -> String {
474 let mut output = String::new();
475
476 if report.importers.is_empty() {
477 output.push_str("No files import this module.\n");
478 return output;
479 }
480
481 let paths: Vec<&Path> = report.importers.iter().map(|i| i.file.as_path()).collect();
483 let prefix = common_path_prefix(&paths);
484
485 let max_loc_width = report
487 .importers
488 .iter()
489 .map(|i| format!("{}:{}", strip_prefix_display(&i.file, &prefix), i.line).len())
490 .max()
491 .unwrap_or(20);
492
493 for imp in &report.importers {
494 let rel_path = strip_prefix_display(&imp.file, &prefix);
495 let loc = format!("{}:{}", rel_path, imp.line);
496 output.push_str(&format!(
497 " {:<width$} {}\n",
498 loc.green(),
499 imp.import_statement.dimmed(),
500 width = max_loc_width,
501 ));
502 }
503
504 output
505}
506
507pub fn format_cfg_text(cfg: &tldr_core::CfgInfo) -> String {
509 let mut output = String::new();
510
511 output.push_str(&format!(
512 "CFG for {} (complexity: {})\n\n",
513 cfg.function.bold().cyan(),
514 cfg.cyclomatic_complexity.to_string().yellow()
515 ));
516
517 let mut table = Table::new();
519 table
520 .load_preset(UTF8_FULL)
521 .set_content_arrangement(ContentArrangement::Dynamic)
522 .set_header(vec![
523 Cell::new("Block").fg(Color::Cyan),
524 Cell::new("Type").fg(Color::Cyan),
525 Cell::new("Lines").fg(Color::Cyan),
526 Cell::new("Calls").fg(Color::Cyan),
527 ]);
528
529 for block in &cfg.blocks {
530 table.add_row(vec![
531 Cell::new(block.id),
532 Cell::new(format!("{:?}", block.block_type)),
533 Cell::new(format!("{}-{}", block.lines.0, block.lines.1)),
534 Cell::new(block.calls.join(", ")),
535 ]);
536 }
537
538 output.push_str(&table.to_string());
539 output.push_str("\n\nEdges:\n");
540
541 for edge in &cfg.edges {
542 let edge_str = match edge.edge_type {
543 tldr_core::EdgeType::True => format!("{} -> {} (true)", edge.from, edge.to).green(),
544 tldr_core::EdgeType::False => format!("{} -> {} (false)", edge.from, edge.to).red(),
545 tldr_core::EdgeType::Unconditional => format!("{} -> {}", edge.from, edge.to).normal(),
546 tldr_core::EdgeType::BackEdge => {
547 format!("{} -> {} (back)", edge.from, edge.to).yellow()
548 }
549 _ => format!("{} -> {} ({:?})", edge.from, edge.to, edge.edge_type).normal(),
550 };
551 output.push_str(&format!(" {}\n", edge_str));
552 }
553
554 output
555}
556
557pub fn format_dfg_text(dfg: &tldr_core::DfgInfo) -> String {
559 let mut output = String::new();
560
561 output.push_str(&format!(
562 "DFG for {} ({} variables)\n\n",
563 dfg.function.bold().cyan(),
564 dfg.variables.len().to_string().yellow()
565 ));
566
567 output.push_str("Variables: ");
568 output.push_str(&dfg.variables.join(", "));
569 output.push_str("\n\n");
570
571 let mut table = Table::new();
573 table
574 .load_preset(UTF8_FULL)
575 .set_content_arrangement(ContentArrangement::Dynamic)
576 .set_header(vec![
577 Cell::new("Var").fg(Color::Cyan),
578 Cell::new("Type").fg(Color::Cyan),
579 Cell::new("Line").fg(Color::Cyan),
580 Cell::new("Col").fg(Color::Cyan),
581 ]);
582
583 for var_ref in &dfg.refs {
584 let type_str = match var_ref.ref_type {
585 tldr_core::RefType::Definition => "def",
586 tldr_core::RefType::Update => "upd",
587 tldr_core::RefType::Use => "use",
588 };
589 table.add_row(vec![
590 Cell::new(&var_ref.name),
591 Cell::new(type_str),
592 Cell::new(var_ref.line),
593 Cell::new(var_ref.column),
594 ]);
595 }
596
597 output.push_str(&table.to_string());
598 output
599}
600
601fn collect_caller_tree_paths<'a>(tree: &'a tldr_core::CallerTree, paths: &mut Vec<&'a Path>) {
603 paths.push(tree.file.as_path());
604 for caller in &tree.callers {
605 collect_caller_tree_paths(caller, paths);
606 }
607}
608
609pub fn format_impact_text(report: &tldr_core::ImpactReport, type_aware: bool) -> String {
611 let mut output = String::new();
612
613 let type_aware_suffix = if type_aware { " (type-aware)" } else { "" };
614 output.push_str(&format!(
615 "Impact Analysis{} ({} targets)\n\n",
616 type_aware_suffix,
617 report.total_targets.to_string().yellow()
618 ));
619
620 if let Some(ref stats) = report.type_resolution {
622 if stats.enabled {
623 output.push_str(&stats.summary());
624 output.push_str("\n\n");
625 }
626 }
627
628 let mut all_paths = Vec::new();
630 for tree in report.targets.values() {
631 collect_caller_tree_paths(tree, &mut all_paths);
632 }
633 let prefix = common_path_prefix(&all_paths);
634
635 for (key, tree) in &report.targets {
636 output.push_str(&format!("{}\n", key.bold().cyan()));
637 format_caller_tree(tree, &mut output, 1, type_aware, &prefix);
638 output.push('\n');
639 }
640
641 output
642}
643
644fn format_caller_tree(
645 tree: &tldr_core::CallerTree,
646 output: &mut String,
647 depth: usize,
648 type_aware: bool,
649 prefix: &Path,
650) {
651 let indent = " ".repeat(depth);
652 let file_str = strip_prefix_display(&tree.file, prefix);
653
654 let confidence_str = if type_aware {
656 if let Some(confidence) = &tree.confidence {
657 format!(" [{}]", confidence)
658 } else {
659 String::new()
660 }
661 } else {
662 String::new()
663 };
664
665 output.push_str(&format!(
666 "{}{}:{} ({} callers){}\n",
667 indent,
668 file_str.dimmed(),
669 tree.function.green(),
670 tree.caller_count,
671 confidence_str
672 ));
673
674 if tree.truncated {
675 output.push_str(&format!("{} [truncated - cycle detected]\n", indent));
676 }
677
678 if let Some(note) = &tree.note {
679 output.push_str(&format!("{} Note: {}\n", indent, note.dimmed()));
680 }
681
682 for caller in &tree.callers {
683 format_caller_tree(caller, output, depth + 1, type_aware, prefix);
684 }
685}
686
687pub fn format_dead_code_text(report: &tldr_core::DeadCodeReport) -> String {
689 let mut output = String::new();
690
691 output.push_str(&format!(
692 "Dead Code Analysis\n\nDefinitely dead: {} / {} functions ({:.1}% dead)\n",
693 report.total_dead.to_string().red(),
694 report.total_functions,
695 report.dead_percentage
696 ));
697
698 if report.total_possibly_dead > 0 {
699 output.push_str(&format!(
700 "Possibly dead (public but uncalled): {}\n",
701 report.total_possibly_dead.to_string().yellow()
702 ));
703 }
704
705 output.push('\n');
706
707 if !report.by_file.is_empty() {
708 let paths: Vec<&Path> = report.by_file.keys().map(|p| p.as_path()).collect();
710 let prefix = common_path_prefix(&paths);
711
712 output.push_str("Definitely dead:\n");
713 for (file, funcs) in &report.by_file {
714 let rel = strip_prefix_display(file, &prefix);
715 output.push_str(&format!("{}\n", rel.green()));
716 for func in funcs {
717 output.push_str(&format!(" - {}\n", func.red()));
718 }
719 output.push('\n');
720 }
721 }
722
723 output
724}
725
726pub fn format_complexity_text(metrics: &tldr_core::types::ComplexityMetrics) -> String {
737 let mut output = String::new();
738
739 output.push_str(&format!("Complexity: {}\n", metrics.function.bold().cyan()));
740 output.push_str(&format!(" Cyclomatic: {}\n", metrics.cyclomatic));
741 output.push_str(&format!(" Cognitive: {}\n", metrics.cognitive));
742 output.push_str(&format!(" Max nesting: {}\n", metrics.max_nesting));
743 output.push_str(&format!(" Lines of code: {}\n", metrics.lines_of_code));
744
745 output
746}
747
748pub fn format_cognitive_text(report: &tldr_core::metrics::CognitiveReport) -> String {
762 let mut output = String::new();
763
764 let violation_count = report.violations.len();
765 output.push_str(&format!(
766 "Cognitive Complexity ({} functions, {} violations)\n\n",
767 report.summary.total_functions,
768 if violation_count > 0 {
769 violation_count.to_string().red().to_string()
770 } else {
771 "0".green().to_string()
772 }
773 ));
774
775 if report.functions.is_empty() {
776 output.push_str(" No functions found.\n");
777 return output;
778 }
779
780 let parents: Vec<&Path> = report
783 .functions
784 .iter()
785 .filter_map(|f| Path::new(f.file.as_str()).parent())
786 .collect();
787 let prefix = if parents.is_empty() {
788 std::path::PathBuf::new()
789 } else {
790 common_path_prefix(&parents)
791 };
792
793 output.push_str(&format!(
795 " {:>3} {:>5} {:>4} {:<9} {:<28} {}\n",
796 "#", "Score", "Nest", "Status", "Function", "File"
797 ));
798
799 for (i, f) in report.functions.iter().enumerate() {
800 let rel = strip_prefix_display(Path::new(&f.file), &prefix);
801 let status = match f.threshold_status {
802 tldr_core::metrics::CognitiveThresholdStatus::Severe => {
803 "SEVERE".red().bold().to_string()
804 }
805 tldr_core::metrics::CognitiveThresholdStatus::Violation => {
806 "VIOLATION".yellow().to_string()
807 }
808 _ => "ok".green().to_string(),
809 };
810
811 let name = if f.name.len() > 28 {
813 format!("{}...", truncate_at_char_boundary(&f.name, 25))
814 } else {
815 f.name.clone()
816 };
817
818 output.push_str(&format!(
819 " {:>3} {:>5} {:>4} {:<9} {:<28} {}:{}\n",
820 i + 1,
821 f.cognitive,
822 f.max_nesting,
823 status,
824 name,
825 rel,
826 f.line
827 ));
828 }
829
830 output.push_str(&format!(
832 "\nSummary: avg={:.1}, max={}, compliance={:.1}%\n",
833 report.summary.avg_cognitive, report.summary.max_cognitive, report.summary.compliance_rate
834 ));
835
836 output
837}
838
839pub fn format_maintainability_text(
855 report: &tldr_core::quality::maintainability::MaintainabilityReport,
856) -> String {
857 let mut output = String::new();
858
859 output.push_str(&format!(
860 "Maintainability Index ({} files, avg MI={:.1})\n\n",
861 report.summary.files_analyzed, report.summary.average_mi
862 ));
863
864 let grades = ['A', 'B', 'C', 'D', 'F'];
866 let mut grade_parts = Vec::new();
867 for g in &grades {
868 let count = report.summary.by_grade.get(g).unwrap_or(&0);
869 if *count > 0 {
870 grade_parts.push(format!("{}={}", g, count));
871 }
872 }
873 output.push_str(&format!(
874 "Grade distribution: {}\n\n",
875 grade_parts.join(" ")
876 ));
877
878 if report.files.is_empty() {
879 output.push_str(" No files analyzed.\n");
880 return output;
881 }
882
883 let mut files: Vec<_> = report.files.iter().collect();
885 files.sort_by(|a, b| a.mi.partial_cmp(&b.mi).unwrap_or(std::cmp::Ordering::Equal));
886
887 let paths: Vec<&Path> = files.iter().filter_map(|f| f.path.parent()).collect();
889 let prefix = common_path_prefix(&paths);
890
891 output.push_str(&format!(
893 " {:>3} {:>5} {:>5} {:>4} {:>5} {}\n",
894 "#", "MI", "Grade", "LOC", "AvgCC", "File"
895 ));
896
897 let limit = files.len().min(30);
899 for (i, f) in files.iter().take(limit).enumerate() {
900 let rel = strip_prefix_display(&f.path, &prefix);
901 let grade_str = match f.grade {
902 'F' => format!("{}", f.grade).red().bold().to_string(),
903 'D' => format!("{}", f.grade).yellow().to_string(),
904 _ => format!("{}", f.grade),
905 };
906
907 output.push_str(&format!(
908 " {:>3} {:>5.1} {:>5} {:>4} {:>5.1} {}\n",
909 i + 1,
910 f.mi,
911 grade_str,
912 f.loc,
913 f.avg_complexity,
914 rel
915 ));
916 }
917
918 if files.len() > limit {
919 output.push_str(&format!("\n ... and {} more files\n", files.len() - limit));
920 }
921
922 output
923}
924
925pub fn format_search_text(matches: &[tldr_core::SearchMatch]) -> String {
927 let mut output = String::new();
928
929 output.push_str(&format!(
930 "Found {} matches\n\n",
931 matches.len().to_string().yellow()
932 ));
933
934 let paths: Vec<&Path> = matches.iter().map(|m| m.file.as_path()).collect();
936 let prefix = common_path_prefix(&paths);
937
938 for m in matches {
939 let rel = strip_prefix_display(&m.file, &prefix);
940 output.push_str(&format!(
941 "{}:{}: {}\n",
942 rel.green(),
943 m.line.to_string().cyan(),
944 m.content.trim()
945 ));
946
947 if let Some(context) = &m.context {
948 for line in context {
949 output.push_str(&format!(" {}\n", line.dimmed()));
950 }
951 }
952 }
953
954 output
955}
956
957pub fn format_enriched_search_text(report: &tldr_core::EnrichedSearchReport) -> String {
964 let mut output = String::new();
965
966 output.push_str(&format!("query: \"{}\"\n", report.query));
967 output.push_str(&format!(
968 "{} results from {} files ({})\n\n",
969 report.results.len(),
970 report.total_files_searched,
971 report.search_mode
972 ));
973
974 if report.results.is_empty() {
975 output.push_str(" No results found.\n");
976 return output;
977 }
978
979 let paths: Vec<&Path> = report.results.iter().map(|r| r.file.as_path()).collect();
981 let prefix = common_path_prefix(&paths);
982
983 for (i, result) in report.results.iter().enumerate() {
984 let rel = strip_prefix_display(&result.file, &prefix);
985 let line_range = format!("{}-{}", result.line_range.0, result.line_range.1);
986
987 let kind_prefix = match result.kind.as_str() {
989 "function" => "fn ",
990 "method" => "method ",
991 "class" => "class ",
992 "struct" => "struct ",
993 "module" => "mod ",
994 _ => "",
995 };
996 output.push_str(&format!(
997 "{}. {}{} ({}:{}) [{:.2}]\n",
998 i + 1,
999 kind_prefix,
1000 result.name,
1001 rel,
1002 line_range,
1003 result.score
1004 ));
1005
1006 if !result.signature.is_empty() {
1008 output.push_str(&format!(" {}\n", result.signature));
1009 }
1010
1011 if !result.callers.is_empty() {
1013 let callers_str = format_name_list(&result.callers, 5);
1014 output.push_str(&format!(" Called by: {}\n", callers_str));
1015 }
1016
1017 if !result.callees.is_empty() {
1019 let callees_str = format_name_list(&result.callees, 5);
1020 output.push_str(&format!(" Calls: {}\n", callees_str));
1021 }
1022
1023 if !result.preview.is_empty() && result.kind != "module" {
1025 let preview_lines: Vec<&str> = result.preview.lines().collect();
1026 let start =
1028 if preview_lines.first().map(|l| l.trim()) == Some(result.signature.as_str()) {
1029 1
1030 } else {
1031 0
1032 };
1033 if start < preview_lines.len() {
1034 output.push_str(" ---\n");
1035 for line in &preview_lines[start..preview_lines.len().min(start + 4)] {
1036 output.push_str(&format!(" {}\n", line));
1037 }
1038 }
1039 }
1040
1041 if i < report.results.len() - 1 {
1043 output.push('\n');
1044 }
1045 }
1046
1047 output
1048}
1049
1050fn format_name_list(names: &[String], max: usize) -> String {
1052 if names.len() <= max {
1053 names.join(", ")
1054 } else {
1055 let shown: Vec<&str> = names[..max].iter().map(|s| s.as_str()).collect();
1056 format!("{}, ... and {} more", shown.join(", "), names.len() - max)
1057 }
1058}
1059
1060pub fn format_smells_text(report: &tldr_core::SmellsReport) -> String {
1062 let mut output = String::new();
1063
1064 output.push_str(&format!(
1065 "Code Smells Report ({} issues)\n\n",
1066 report.smells.len().to_string().yellow()
1067 ));
1068
1069 for warning in &report.warnings {
1075 output.push_str(&format!("{}\n", warning));
1076 }
1077 if !report.warnings.is_empty() {
1078 output.push('\n');
1079 }
1080
1081 if report.smells.is_empty() {
1082 output.push_str(" No code smells detected.\n");
1083 return output;
1084 }
1085
1086 let paths: Vec<&Path> = report.smells.iter().map(|s| s.file.as_path()).collect();
1088 let prefix = if paths.is_empty() {
1089 std::path::PathBuf::new()
1090 } else {
1091 common_path_prefix(&paths)
1092 };
1093
1094 output.push_str(&format!(
1096 " {:>3} {:>3} {:<20} {:<28} {}\n",
1097 "#", "Sev", "Type", "Name", "File:Line"
1098 ));
1099
1100 for (i, smell) in report.smells.iter().enumerate() {
1101 let sev_str = match smell.severity {
1103 3 => smell.severity.to_string().red(),
1104 2 => smell.severity.to_string().yellow(),
1105 _ => smell.severity.to_string().white(),
1106 }
1107 .to_string();
1108
1109 let type_str = {
1111 let base = format!("{}", smell.smell_type);
1112 let colored = match smell.smell_type {
1113 tldr_core::SmellType::GodClass => base.red(),
1114 tldr_core::SmellType::LongMethod => base.yellow(),
1115 tldr_core::SmellType::LongParameterList => base.magenta(),
1116 tldr_core::SmellType::LowCohesion => base.yellow(),
1117 tldr_core::SmellType::TightCoupling => base.red(),
1118 tldr_core::SmellType::DeadCode => base.dimmed(),
1119 tldr_core::SmellType::CodeClone => base.cyan(),
1120 tldr_core::SmellType::HighCognitiveComplexity => base.red(),
1121 tldr_core::SmellType::DeepNesting => base.yellow(),
1122 tldr_core::SmellType::DataClass => base.cyan(),
1123 tldr_core::SmellType::LazyElement => base.dimmed(),
1124 tldr_core::SmellType::MessageChain => base.magenta(),
1125 tldr_core::SmellType::PrimitiveObsession => base.cyan(),
1126 tldr_core::SmellType::FeatureEnvy => base.yellow(),
1127 tldr_core::SmellType::MiddleMan => base.yellow(),
1128 tldr_core::SmellType::RefusedBequest => base.magenta(),
1129 tldr_core::SmellType::InappropriateIntimacy => base.red(),
1130 tldr_core::SmellType::DataClumps => base.white(),
1131 };
1132 colored.to_string()
1133 };
1134
1135 let name = if smell.name.len() > 28 {
1137 format!("{}...", truncate_at_char_boundary(&smell.name, 25))
1138 } else {
1139 smell.name.clone()
1140 };
1141
1142 let rel_file = strip_prefix_display(&smell.file, &prefix);
1144
1145 output.push_str(&format!(
1146 " {:>3} {:>3} {:<20} {:<28} {}:{}\n",
1147 i + 1,
1148 sev_str,
1149 type_str,
1150 name,
1151 rel_file,
1152 smell.line
1153 ));
1154 }
1155
1156 output.push('\n');
1158
1159 let sev3 = report.smells.iter().filter(|s| s.severity == 3).count();
1160 let sev2 = report.smells.iter().filter(|s| s.severity == 2).count();
1161 let sev1 = report.smells.iter().filter(|s| s.severity == 1).count();
1162 let unique_files = report.by_file.len();
1163 output.push_str(&format!(
1164 "Summary: {} smells found ({} {}, {} {}, {} {}) across {} files\n",
1165 report.smells.len(),
1166 sev3,
1167 "sev-3".red(),
1168 sev2,
1169 "sev-2".yellow(),
1170 sev1,
1171 "sev-1",
1172 unique_files,
1173 ));
1174
1175 let mut type_counts: Vec<(String, usize)> = report
1177 .summary
1178 .by_type
1179 .iter()
1180 .map(|(k, v)| (k.clone(), *v))
1181 .collect();
1182 type_counts.sort_by(|a, b| b.1.cmp(&a.1));
1183 let breakdown: Vec<String> = type_counts
1184 .iter()
1185 .map(|(name, count)| format!("{}: {}", name, count))
1186 .collect();
1187 output.push_str(&format!(" {}\n", breakdown.join(", ")));
1188
1189 output
1190}
1191
1192pub fn format_secrets_text(report: &tldr_core::SecretsReport) -> String {
1194 let mut output = String::new();
1195
1196 output.push_str(&format!(
1197 "Secrets Scan ({} findings, {} files scanned)\n\n",
1198 report.findings.len().to_string().yellow(),
1199 report.files_scanned
1200 ));
1201
1202 if report.findings.is_empty() {
1203 output.push_str(" No secrets detected.\n");
1204 return output;
1205 }
1206
1207 let paths: Vec<&Path> = report.findings.iter().map(|f| f.file.as_path()).collect();
1209 let prefix = if paths.is_empty() {
1210 std::path::PathBuf::new()
1211 } else {
1212 common_path_prefix(&paths)
1213 };
1214
1215 output.push_str(&format!(
1217 " {:<8} {:<14} {:<40} {:>5} {}\n",
1218 "Severity", "Pattern", "File", "Line", "Value"
1219 ));
1220
1221 for finding in &report.findings {
1222 let sev_str = match finding.severity {
1223 tldr_core::Severity::Critical => finding.severity.to_string().red(),
1224 tldr_core::Severity::High => finding.severity.to_string().red(),
1225 tldr_core::Severity::Medium => finding.severity.to_string().yellow(),
1226 tldr_core::Severity::Low => finding.severity.to_string().white(),
1227 }
1228 .to_string();
1229
1230 let rel_file = strip_prefix_display(&finding.file, &prefix);
1231
1232 let file_display = if rel_file.len() > 40 {
1234 format!(
1235 "...{}",
1236 truncate_at_char_boundary_from_end(&rel_file, 37)
1237 )
1238 } else {
1239 rel_file
1240 };
1241
1242 output.push_str(&format!(
1243 " {:<8} {:<14} {:<40} {:>5} {}\n",
1244 sev_str, finding.pattern, file_display, finding.line, finding.masked_value
1245 ));
1246 }
1247
1248 output.push('\n');
1250 let critical = report
1251 .findings
1252 .iter()
1253 .filter(|f| f.severity == tldr_core::Severity::Critical)
1254 .count();
1255 let high = report
1256 .findings
1257 .iter()
1258 .filter(|f| f.severity == tldr_core::Severity::High)
1259 .count();
1260 let medium = report
1261 .findings
1262 .iter()
1263 .filter(|f| f.severity == tldr_core::Severity::Medium)
1264 .count();
1265 let low = report
1266 .findings
1267 .iter()
1268 .filter(|f| f.severity == tldr_core::Severity::Low)
1269 .count();
1270 let mut parts = Vec::new();
1271 if critical > 0 {
1272 parts.push(format!("{} {}", critical, "critical".red()));
1273 }
1274 if high > 0 {
1275 parts.push(format!("{} {}", high, "high".red()));
1276 }
1277 if medium > 0 {
1278 parts.push(format!("{} {}", medium, "medium".yellow()));
1279 }
1280 if low > 0 {
1281 parts.push(format!("{} {}", low, "low"));
1282 }
1283 output.push_str(&format!("Summary: {}\n", parts.join(", ")));
1284
1285 output
1286}
1287
1288pub fn format_whatbreaks_text(
1313 report: &tldr_core::analysis::whatbreaks::WhatbreaksReport,
1314) -> String {
1315 let mut output = String::new();
1316
1317 output.push_str(&format!(
1319 "What Breaks: {} ({})\n",
1320 report.target.bold().cyan(),
1321 report.target_type.to_string().yellow()
1322 ));
1323 output.push('\n');
1324
1325 let summary = &report.summary;
1327
1328 if summary.direct_caller_count > 0
1329 || report.target_type == tldr_core::analysis::whatbreaks::TargetType::Function
1330 {
1331 output.push_str(&format!(
1332 "Direct callers: {}\n",
1333 if summary.direct_caller_count > 0 {
1334 summary.direct_caller_count.to_string().green().to_string()
1335 } else {
1336 "0".to_string()
1337 }
1338 ));
1339 output.push_str(&format!(
1340 "Transitive callers: {}\n",
1341 if summary.transitive_caller_count > 0 {
1342 summary
1343 .transitive_caller_count
1344 .to_string()
1345 .green()
1346 .to_string()
1347 } else {
1348 "0".to_string()
1349 }
1350 ));
1351 }
1352
1353 if summary.importer_count > 0
1354 || report.target_type != tldr_core::analysis::whatbreaks::TargetType::Function
1355 {
1356 output.push_str(&format!(
1357 "Importing modules: {}\n",
1358 if summary.importer_count > 0 {
1359 format!("{} files", summary.importer_count)
1360 .green()
1361 .to_string()
1362 } else {
1363 "0 files".to_string()
1364 }
1365 ));
1366 }
1367
1368 if summary.affected_test_count > 0
1369 || report.target_type == tldr_core::analysis::whatbreaks::TargetType::File
1370 {
1371 output.push_str(&format!(
1372 "Affected tests: {}\n",
1373 if summary.affected_test_count > 0 {
1374 format!("{} test files", summary.affected_test_count)
1375 .yellow()
1376 .to_string()
1377 } else {
1378 "0 test files".to_string()
1379 }
1380 ));
1381 }
1382
1383 output.push('\n');
1384
1385 let has_errors = report
1387 .sub_results
1388 .values()
1389 .any(|r| r.error.is_some() || !r.warnings.is_empty());
1390 if has_errors {
1391 output.push_str("Issues:\n");
1392
1393 let mut sub_results: Vec<_> = report.sub_results.iter().collect();
1394 sub_results.sort_by_key(|(name, _)| *name);
1395
1396 for (name, result) in sub_results {
1397 if let Some(error) = &result.error {
1398 output.push_str(&format!(" {} error: {}\n", name, error.red()));
1399 }
1400 for warning in &result.warnings {
1401 output.push_str(&format!(" {} warning: {}\n", name, warning.yellow()));
1402 }
1403 }
1404 }
1405
1406 output
1407}
1408
1409pub fn format_hubs_text(report: &tldr_core::analysis::hubs::HubReport) -> String {
1420 let mut output = String::new();
1421
1422 output.push_str(&format!(
1424 "Hub Detection ({} hubs / {} nodes)\n\n",
1425 report.hub_count.to_string().yellow(),
1426 report.total_nodes,
1427 ));
1428
1429 if report.hubs.is_empty() {
1431 output.push_str("No hubs found.\n");
1432 return output;
1433 }
1434
1435 let paths: Vec<&Path> = report.hubs.iter().map(|h| h.file.as_path()).collect();
1437 let prefix = common_path_prefix(&paths);
1438
1439 let max_func = report
1441 .hubs
1442 .iter()
1443 .map(|h| h.name.len())
1444 .max()
1445 .unwrap_or(8)
1446 .max(8);
1447 let max_file = report
1448 .hubs
1449 .iter()
1450 .map(|h| strip_prefix_display(&h.file, &prefix).len())
1451 .max()
1452 .unwrap_or(4)
1453 .max(4);
1454
1455 output.push_str(&format!(
1457 " {:<3} {:<8} {:<width_f$} {:<width_p$} {:>5} {:>3} {:>3}\n",
1458 "#",
1459 "Risk",
1460 "Function",
1461 "File",
1462 "Score",
1463 "In",
1464 "Out",
1465 width_f = max_func,
1466 width_p = max_file,
1467 ));
1468
1469 for (i, hub) in report.hubs.iter().enumerate() {
1470 let risk_str = format!("{}", hub.risk_level).to_uppercase();
1471 let rel_file = strip_prefix_display(&hub.file, &prefix);
1472
1473 output.push_str(&format!(
1474 " {:<3} {:<8} {:<width_f$} {:<width_p$} {:>5.3} {:>3} {:>3}\n",
1475 i + 1,
1476 risk_str,
1477 hub.name,
1478 rel_file,
1479 hub.composite_score,
1480 hub.callers_count,
1481 hub.callees_count,
1482 width_f = max_func,
1483 width_p = max_file,
1484 ));
1485 }
1486
1487 output
1488}
1489
1490pub fn format_change_impact_text(report: &tldr_core::ChangeImpactReport) -> String {
1495 let mut output = String::new();
1496
1497 output.push_str(&"Change Impact Analysis\n".bold().to_string());
1499 output.push_str("======================\n\n");
1500
1501 output.push_str(&format!("Detection: {}\n", report.detection_method.cyan()));
1503
1504 output.push_str(&format!(
1506 "Changed: {} files\n\n",
1507 report.changed_files.len().to_string().yellow()
1508 ));
1509
1510 if !report.changed_files.is_empty() {
1511 output.push_str(&"Changed Files:\n".bold().to_string());
1512 for file in &report.changed_files {
1513 output.push_str(&format!(" {}\n", file.display().to_string().green()));
1514 }
1515 output.push('\n');
1516 }
1517
1518 let test_func_count = report.affected_test_functions.len();
1520 output.push_str(&format!(
1521 "Affected Tests: {} files, {} functions\n",
1522 report.affected_tests.len().to_string().yellow(),
1523 test_func_count.to_string().yellow()
1524 ));
1525
1526 if !report.affected_tests.is_empty() {
1527 for test in &report.affected_tests {
1528 output.push_str(&format!(" {}\n", test.display().to_string().cyan()));
1529 for tf in &report.affected_test_functions {
1531 if tf.file == *test {
1532 let func_name = if let Some(ref class) = tf.class {
1533 format!("{}::{}", class, tf.function)
1534 } else {
1535 tf.function.clone()
1536 };
1537 output.push_str(&format!(" - {} (line {})\n", func_name.green(), tf.line));
1538 }
1539 }
1540 }
1541 output.push('\n');
1542 } else {
1543 output.push_str(" No tests affected.\n\n");
1544 }
1545
1546 if !report.affected_functions.is_empty() {
1548 output.push_str(&format!(
1549 "Affected Functions: {}\n",
1550 report.affected_functions.len().to_string().yellow()
1551 ));
1552 for func in &report.affected_functions {
1553 output.push_str(&format!(
1554 " {} ({})\n",
1555 func.name.green(),
1556 func.file.display().to_string().dimmed()
1557 ));
1558 }
1559 output.push('\n');
1560 }
1561
1562 if let Some(ref metadata) = report.metadata {
1564 output.push_str(&format!(
1565 "Call Graph: {} edges\n",
1566 metadata.call_graph_edges
1567 ));
1568 output.push_str(&format!(
1569 "Traversal Depth: {}\n",
1570 metadata.analysis_depth.unwrap_or(0)
1571 ));
1572 }
1573
1574 output
1575}
1576
1577pub fn format_diagnostics_text(
1583 report: &tldr_core::diagnostics::DiagnosticsReport,
1584 filtered_count: usize,
1585) -> String {
1586 let mut output = String::new();
1587
1588 let tool_names: Vec<&str> = report.tools_run.iter().map(|t| t.name.as_str()).collect();
1591 let tools_part = tool_names.join(" + ");
1592
1593 let summary = &report.summary;
1594 let mut counts: Vec<String> = Vec::new();
1595 if summary.errors > 0 {
1596 counts.push(format!(
1597 "{} {}",
1598 summary.errors,
1599 if summary.errors == 1 {
1600 "error"
1601 } else {
1602 "errors"
1603 }
1604 ));
1605 }
1606 if summary.warnings > 0 {
1607 counts.push(format!(
1608 "{} {}",
1609 summary.warnings,
1610 if summary.warnings == 1 {
1611 "warning"
1612 } else {
1613 "warnings"
1614 }
1615 ));
1616 }
1617 if summary.info > 0 {
1618 counts.push(format!(
1619 "{} {}",
1620 summary.info,
1621 if summary.info == 1 { "info" } else { "infos" }
1622 ));
1623 }
1624 if summary.hints > 0 {
1625 counts.push(format!(
1626 "{} {}",
1627 summary.hints,
1628 if summary.hints == 1 { "hint" } else { "hints" }
1629 ));
1630 }
1631
1632 let counts_part = if counts.is_empty() {
1633 "No issues found".to_string()
1634 } else {
1635 counts.join(", ")
1636 };
1637
1638 output.push_str(&format!(
1639 "{} | {} files | {}\n",
1640 tools_part, report.files_analyzed, counts_part
1641 ));
1642
1643 if report.diagnostics.is_empty() {
1645 } else {
1647 output.push('\n');
1648
1649 let parents: Vec<&std::path::Path> = report
1654 .diagnostics
1655 .iter()
1656 .filter_map(|d| d.file.parent())
1657 .collect();
1658 let prefix = common_path_prefix(&parents);
1659
1660 let mut sorted_diags: Vec<&tldr_core::diagnostics::Diagnostic> =
1662 report.diagnostics.iter().collect();
1663 sorted_diags.sort_by(|a, b| {
1664 a.file
1665 .cmp(&b.file)
1666 .then(a.line.cmp(&b.line))
1667 .then(a.column.cmp(&b.column))
1668 });
1669
1670 for diag in &sorted_diags {
1671 let rel_path = strip_prefix_display(&diag.file, &prefix);
1672
1673 let severity_str = match diag.severity {
1675 tldr_core::diagnostics::Severity::Error => "error",
1676 tldr_core::diagnostics::Severity::Warning => "warning",
1677 tldr_core::diagnostics::Severity::Information => "info",
1678 tldr_core::diagnostics::Severity::Hint => "hint",
1679 };
1680
1681 let code_str = diag
1683 .code
1684 .as_ref()
1685 .map(|c| format!("[{}]", c))
1686 .unwrap_or_default();
1687
1688 let message = diag.message.lines().next().unwrap_or(&diag.message);
1690
1691 output.push_str(&format!(
1694 "{}:{}:{}: {}{} {} ({})\n",
1695 rel_path, diag.line, diag.column, severity_str, code_str, message, diag.source
1696 ));
1697 }
1698 }
1699
1700 if filtered_count > 0 {
1702 output.push_str(&format!(
1703 "\n({} issues filtered by severity/ignore settings)\n",
1704 filtered_count
1705 ));
1706 }
1707
1708 output
1709}
1710
1711pub fn clone_type_description(clone_type: &tldr_core::analysis::CloneType) -> &'static str {
1720 use tldr_core::analysis::CloneType;
1721 match clone_type {
1722 CloneType::Type1 => "exact match (identical code)",
1723 CloneType::Type2 => "identical structure, renamed identifiers/literals",
1724 CloneType::Type3 => "similar structure with additions/deletions",
1725 }
1726}
1727
1728pub fn empty_results_hints(
1732 options: &tldr_core::analysis::ClonesOptions,
1733 stats: &tldr_core::analysis::CloneStats,
1734) -> Vec<String> {
1735 vec![
1736 format!(
1737 "Analyzed {} files, {} tokens",
1738 stats.files_analyzed, stats.total_tokens
1739 ),
1740 format!(
1741 "Current threshold: {:.0}% - try --threshold 0.6 for more matches",
1742 options.threshold * 100.0
1743 ),
1744 format!(
1745 "Current min-tokens: {} - try --min-tokens 30 for smaller clones",
1746 options.min_tokens
1747 ),
1748 ]
1749}
1750
1751pub fn escape_dot_id(id: &str) -> String {
1758 let normalized = id.replace('\\', "/");
1760
1761 let escaped = normalized.replace('"', r#"\""#);
1763
1764 format!("\"{}\"", escaped)
1766}
1767
1768pub fn format_clones_text(report: &tldr_core::analysis::ClonesReport) -> String {
1786 let mut output = String::new();
1787
1788 output.push_str(&format!(
1790 "Clone Detection: {} pairs in {} files ({} tokens)\n",
1791 report.stats.clones_found, report.stats.files_analyzed, report.stats.total_tokens
1792 ));
1793
1794 if report.clone_pairs.is_empty() {
1795 output.push_str("\nNo clones found.\n");
1796 return output;
1797 }
1798
1799 output.push('\n');
1800
1801 let all_paths: Vec<&Path> = report
1803 .clone_pairs
1804 .iter()
1805 .flat_map(|p| [p.fragment1.file.as_path(), p.fragment2.file.as_path()])
1806 .collect();
1807 let prefix = common_path_prefix(&all_paths);
1808
1809 output.push_str(&format!(
1811 " {:>2} {:>3} {:<4} {:<30} {:>9} {:<30} {:>9}\n",
1812 "#", "Sim", "Type", "File A", "Lines", "File B", "Lines"
1813 ));
1814
1815 for pair in &report.clone_pairs {
1816 let sim = (pair.similarity * 100.0) as u32;
1817 let type_short = match pair.clone_type {
1818 tldr_core::analysis::CloneType::Type1 => "T1",
1819 tldr_core::analysis::CloneType::Type2 => "T2",
1820 tldr_core::analysis::CloneType::Type3 => "T3",
1821 };
1822
1823 let file_a = strip_prefix_display(&pair.fragment1.file, &prefix);
1824 let file_b = strip_prefix_display(&pair.fragment2.file, &prefix);
1825 let lines_a = format!("{}-{}", pair.fragment1.start_line, pair.fragment1.end_line);
1826 let lines_b = format!("{}-{}", pair.fragment2.start_line, pair.fragment2.end_line);
1827
1828 let file_a_display = if file_a.len() > 30 {
1831 format!(
1832 "...{}",
1833 truncate_at_char_boundary_from_end(&file_a, 27)
1834 )
1835 } else {
1836 file_a
1837 };
1838 let file_b_display = if file_b.len() > 30 {
1839 format!(
1840 "...{}",
1841 truncate_at_char_boundary_from_end(&file_b, 27)
1842 )
1843 } else {
1844 file_b
1845 };
1846
1847 output.push_str(&format!(
1848 " {:>2} {:>3}% {:<4} {:<30} {:>9} {:<30} {:>9}\n",
1849 pair.id, sim, type_short, file_a_display, lines_a, file_b_display, lines_b
1850 ));
1851 }
1852
1853 output
1854}
1855
1856pub fn format_clones_dot(report: &tldr_core::analysis::ClonesReport) -> String {
1870 let mut output = String::new();
1871
1872 output.push_str("digraph clones {\n");
1873 output.push_str(" rankdir=LR;\n");
1874 output.push_str(" node [shape=box, fontname=\"Helvetica\"];\n");
1875 output.push_str(" edge [fontname=\"Helvetica\", fontsize=10];\n");
1876 output.push('\n');
1877
1878 for pair in &report.clone_pairs {
1880 let node1 = format!(
1881 "{}:{}-{}",
1882 pair.fragment1.file.display(),
1883 pair.fragment1.start_line,
1884 pair.fragment1.end_line
1885 );
1886 let node2 = format!(
1887 "{}:{}-{}",
1888 pair.fragment2.file.display(),
1889 pair.fragment2.start_line,
1890 pair.fragment2.end_line
1891 );
1892
1893 let node1_escaped = escape_dot_id(&node1);
1895 let node2_escaped = escape_dot_id(&node2);
1896
1897 let similarity_pct = (pair.similarity * 100.0) as u32;
1898 let type_abbrev = match pair.clone_type {
1899 tldr_core::analysis::CloneType::Type1 => "T1",
1900 tldr_core::analysis::CloneType::Type2 => "T2",
1901 tldr_core::analysis::CloneType::Type3 => "T3",
1902 };
1903
1904 output.push_str(&format!(
1905 " {} -> {} [label=\"{}% {}\"];\n",
1906 node1_escaped, node2_escaped, similarity_pct, type_abbrev
1907 ));
1908 }
1909
1910 output.push_str("}\n");
1911 output
1912}
1913
1914pub struct DotCallEdge<'a> {
1922 pub src: &'a str,
1924 pub dst: &'a str,
1926 pub label: Option<&'a str>,
1928}
1929
1930pub fn format_calls_dot(edges: &[DotCallEdge<'_>]) -> String {
1937 let mut output = String::new();
1938 output.push_str("digraph calls {\n");
1939 output.push_str(" rankdir=LR;\n");
1940 output.push_str(" node [shape=box, fontname=\"Helvetica\"];\n");
1941 output.push_str(" edge [fontname=\"Helvetica\", fontsize=10];\n");
1942 output.push('\n');
1943
1944 for edge in edges {
1945 let src = escape_dot_id(edge.src);
1946 let dst = escape_dot_id(edge.dst);
1947 match edge.label {
1948 Some(lbl) => output.push_str(&format!(
1949 " {} -> {} [label=\"{}\"];\n",
1950 src,
1951 dst,
1952 lbl.replace('"', "\\\"")
1953 )),
1954 None => output.push_str(&format!(" {} -> {};\n", src, dst)),
1955 }
1956 }
1957
1958 output.push_str("}\n");
1959 output
1960}
1961
1962pub fn format_impact_dot(report: &tldr_core::types::ImpactReport) -> String {
1970 let mut output = String::new();
1971 output.push_str("digraph impact {\n");
1972 output.push_str(" rankdir=RL;\n");
1973 output.push_str(" node [shape=box, fontname=\"Helvetica\"];\n");
1974 output.push_str(" edge [fontname=\"Helvetica\", fontsize=10];\n");
1975 output.push('\n');
1976
1977 let mut target_names: Vec<&String> = report.targets.keys().collect();
1979 target_names.sort();
1980
1981 for tname in target_names {
1982 if let Some(tree) = report.targets.get(tname) {
1983 emit_impact_caller_edges(&mut output, tree);
1984 }
1985 }
1986
1987 output.push_str("}\n");
1988 output
1989}
1990
1991fn emit_impact_caller_edges(output: &mut String, node: &tldr_core::types::CallerTree) {
1994 let callee_id = format!("{}:{}", node.file.display(), node.function);
1995 let callee_escaped = escape_dot_id(&callee_id);
1996
1997 for caller in &node.callers {
1998 let caller_id = format!("{}:{}", caller.file.display(), caller.function);
1999 let caller_escaped = escape_dot_id(&caller_id);
2000 output.push_str(&format!(" {} -> {};\n", caller_escaped, callee_escaped));
2001 emit_impact_caller_edges(output, caller);
2003 }
2004}
2005
2006pub fn format_hubs_dot(report: &tldr_core::analysis::hubs::HubReport) -> String {
2014 let mut output = String::new();
2015 output.push_str("digraph hubs {\n");
2016 output.push_str(" rankdir=LR;\n");
2017 output.push_str(" node [shape=box, fontname=\"Helvetica\"];\n");
2018 output.push('\n');
2019
2020 for hub in &report.hubs {
2021 let id = format!("{}:{}", hub.file.display(), hub.name);
2022 let escaped = escape_dot_id(&id);
2023 let label = format!("{} (score={:.3})", hub.name, hub.composite_score);
2026 let label_escaped = label.replace('"', "\\\"");
2027 output.push_str(&format!(
2028 " {} [label=\"{}\"];\n",
2029 escaped, label_escaped
2030 ));
2031 }
2032 if report.hubs.len() >= 2 {
2036 for window in report.hubs.windows(2) {
2037 let a = format!("{}:{}", window[0].file.display(), window[0].name);
2038 let b = format!("{}:{}", window[1].file.display(), window[1].name);
2039 output.push_str(&format!(
2040 " {} -> {} [style=invis];\n",
2041 escape_dot_id(&a),
2042 escape_dot_id(&b)
2043 ));
2044 }
2045 }
2046
2047 output.push_str("}\n");
2048 output
2049}
2050
2051pub fn format_similarity_text(report: &tldr_core::analysis::SimilarityReport) -> String {
2078 let mut output = String::new();
2079
2080 output.push_str(&"Similarity Analysis\n".bold().to_string());
2082 output.push_str("===================\n\n");
2083
2084 output.push_str(&format!(
2086 "Fragment 1: {} ({} tokens, {} lines)\n",
2087 report.fragment1.file.display().to_string().cyan(),
2088 report.fragment1.tokens,
2089 report.fragment1.lines
2090 ));
2091 if let Some(func) = &report.fragment1.function {
2092 output.push_str(&format!(" Function: {}\n", func.green()));
2093 }
2094 if let Some((start, end)) = report.fragment1.line_range {
2095 output.push_str(&format!(" Lines: {}-{}\n", start, end));
2096 }
2097
2098 output.push_str(&format!(
2099 "Fragment 2: {} ({} tokens, {} lines)\n",
2100 report.fragment2.file.display().to_string().cyan(),
2101 report.fragment2.tokens,
2102 report.fragment2.lines
2103 ));
2104 if let Some(func) = &report.fragment2.function {
2105 output.push_str(&format!(" Function: {}\n", func.green()));
2106 }
2107 if let Some((start, end)) = report.fragment2.line_range {
2108 output.push_str(&format!(" Lines: {}-{}\n", start, end));
2109 }
2110
2111 output.push('\n');
2112
2113 output.push_str(&"Similarity Scores:\n".bold().to_string());
2115 let dice_pct = (report.similarity.dice * 100.0) as u32;
2116 let jaccard_pct = (report.similarity.jaccard * 100.0) as u32;
2117
2118 output.push_str(&format!(
2119 " Dice: {:.4} ({}%)\n",
2120 report.similarity.dice,
2121 dice_pct.to_string().green()
2122 ));
2123 output.push_str(&format!(
2124 " Jaccard: {:.4} ({}%)\n",
2125 report.similarity.jaccard,
2126 jaccard_pct.to_string().green()
2127 ));
2128
2129 if let Some(cosine) = report.similarity.cosine {
2130 let cosine_pct = (cosine * 100.0) as u32;
2131 output.push_str(&format!(
2132 " Cosine: {:.4} ({}%)\n",
2133 cosine,
2134 cosine_pct.to_string().green()
2135 ));
2136 }
2137
2138 output.push('\n');
2139
2140 output.push_str(&format!(
2142 "Interpretation: {}\n\n",
2143 report.similarity.interpretation.cyan()
2144 ));
2145
2146 output.push_str(&"Token Breakdown:\n".bold().to_string());
2148 output.push_str(&format!(
2149 " Shared tokens: {}\n",
2150 report.token_breakdown.shared_tokens.to_string().green()
2151 ));
2152 output.push_str(&format!(
2153 " Unique to #1: {}\n",
2154 report.token_breakdown.unique_to_fragment1
2155 ));
2156 output.push_str(&format!(
2157 " Unique to #2: {}\n",
2158 report.token_breakdown.unique_to_fragment2
2159 ));
2160 output.push_str(&format!(
2161 " Total unique: {}\n",
2162 report.token_breakdown.total_unique
2163 ));
2164
2165 output.push('\n');
2167 output.push_str(&format!(
2168 "Metric: {:?}, N-gram size: {}\n",
2169 report.config.metric, report.config.ngram_size
2170 ));
2171
2172 if let Some(lang) = &report.config.language {
2173 output.push_str(&format!("Language: {}\n", lang));
2174 }
2175
2176 output
2177}
2178
2179pub mod sarif {
2193 use serde::Serialize;
2194 use std::path::Path;
2195 use tldr_core::analysis::{CloneType, ClonesReport};
2196
2197 #[derive(Debug, Serialize)]
2199 pub struct SarifLog {
2200 #[serde(rename = "$schema")]
2201 pub schema: String,
2202 pub version: String,
2203 pub runs: Vec<SarifRun>,
2204 }
2205
2206 #[derive(Debug, Serialize)]
2208 pub struct SarifRun {
2209 pub tool: SarifTool,
2210 pub results: Vec<SarifResult>,
2211 #[serde(skip_serializing_if = "Option::is_none")]
2212 pub invocations: Option<Vec<SarifInvocation>>,
2213 }
2214
2215 #[derive(Debug, Serialize)]
2217 pub struct SarifTool {
2218 pub driver: SarifDriver,
2219 }
2220
2221 #[derive(Debug, Serialize)]
2223 pub struct SarifDriver {
2224 pub name: String,
2225 pub version: String,
2226 #[serde(rename = "informationUri", skip_serializing_if = "Option::is_none")]
2227 pub information_uri: Option<String>,
2228 pub rules: Vec<SarifRule>,
2229 }
2230
2231 #[derive(Debug, Serialize)]
2233 pub struct SarifRule {
2234 pub id: String,
2235 pub name: String,
2236 #[serde(rename = "shortDescription")]
2237 pub short_description: SarifMessage,
2238 #[serde(rename = "fullDescription", skip_serializing_if = "Option::is_none")]
2239 pub full_description: Option<SarifMessage>,
2240 #[serde(rename = "helpUri", skip_serializing_if = "Option::is_none")]
2241 pub help_uri: Option<String>,
2242 #[serde(
2243 rename = "defaultConfiguration",
2244 skip_serializing_if = "Option::is_none"
2245 )]
2246 pub default_configuration: Option<SarifConfiguration>,
2247 }
2248
2249 #[derive(Debug, Serialize)]
2251 pub struct SarifConfiguration {
2252 pub level: String,
2253 }
2254
2255 #[derive(Debug, Serialize)]
2257 pub struct SarifResult {
2258 #[serde(rename = "ruleId")]
2259 pub rule_id: String,
2260 pub level: String,
2261 pub message: SarifMessage,
2262 pub locations: Vec<SarifLocation>,
2263 #[serde(rename = "relatedLocations", skip_serializing_if = "Vec::is_empty")]
2264 pub related_locations: Vec<SarifLocation>,
2265 #[serde(
2266 rename = "partialFingerprints",
2267 skip_serializing_if = "Option::is_none"
2268 )]
2269 pub partial_fingerprints: Option<SarifFingerprints>,
2270 }
2271
2272 #[derive(Debug, Serialize)]
2274 pub struct SarifMessage {
2275 pub text: String,
2276 }
2277
2278 #[derive(Debug, Serialize)]
2280 pub struct SarifLocation {
2281 #[serde(rename = "physicalLocation")]
2282 pub physical_location: SarifPhysicalLocation,
2283 #[serde(skip_serializing_if = "Option::is_none")]
2284 pub id: Option<usize>,
2285 }
2286
2287 #[derive(Debug, Serialize)]
2289 pub struct SarifPhysicalLocation {
2290 #[serde(rename = "artifactLocation")]
2291 pub artifact_location: SarifArtifactLocation,
2292 pub region: SarifRegion,
2293 }
2294
2295 #[derive(Debug, Serialize)]
2297 pub struct SarifArtifactLocation {
2298 pub uri: String,
2299 #[serde(rename = "uriBaseId", skip_serializing_if = "Option::is_none")]
2300 pub uri_base_id: Option<String>,
2301 }
2302
2303 #[derive(Debug, Serialize)]
2305 pub struct SarifRegion {
2306 #[serde(rename = "startLine")]
2307 pub start_line: usize,
2308 #[serde(rename = "endLine", skip_serializing_if = "Option::is_none")]
2309 pub end_line: Option<usize>,
2310 }
2311
2312 #[derive(Debug, Serialize)]
2314 pub struct SarifFingerprints {
2315 #[serde(
2316 rename = "primaryLocationLineHash",
2317 skip_serializing_if = "Option::is_none"
2318 )]
2319 pub primary_location_line_hash: Option<String>,
2320 }
2321
2322 #[derive(Debug, Serialize)]
2324 pub struct SarifInvocation {
2325 #[serde(rename = "executionSuccessful")]
2326 pub execution_successful: bool,
2327 }
2328
2329 fn clone_type_rule_id(clone_type: CloneType) -> &'static str {
2331 match clone_type {
2332 CloneType::Type1 => "clone/type-1",
2333 CloneType::Type2 => "clone/type-2",
2334 CloneType::Type3 => "clone/type-3",
2335 }
2336 }
2337
2338 fn clone_type_description(clone_type: CloneType) -> &'static str {
2340 match clone_type {
2341 CloneType::Type1 => "Exact code clone (identical except whitespace/comments)",
2342 CloneType::Type2 => "Parameterized clone (renamed identifiers/literals)",
2343 CloneType::Type3 => "Gapped clone (structural similarity with modifications)",
2344 }
2345 }
2346
2347 fn clone_type_level(clone_type: CloneType) -> &'static str {
2349 match clone_type {
2350 CloneType::Type1 => "warning", CloneType::Type2 => "warning",
2352 CloneType::Type3 => "note", }
2354 }
2355
2356 fn path_to_uri(path: &Path, root: &Path) -> String {
2358 let relative = path.strip_prefix(root).unwrap_or(path);
2360 relative.to_string_lossy().replace('\\', "/")
2361 }
2362
2363 pub fn format_clones_sarif(report: &ClonesReport) -> SarifLog {
2365 let rules = vec![
2367 SarifRule {
2368 id: "clone/type-1".to_string(),
2369 name: "ExactClone".to_string(),
2370 short_description: SarifMessage {
2371 text: "Exact code clone detected".to_string(),
2372 },
2373 full_description: Some(SarifMessage {
2374 text: "Type-1 clone: Identical code fragments (ignoring whitespace and comments). Consider extracting to a shared function or module.".to_string(),
2375 }),
2376 help_uri: None,
2377 default_configuration: Some(SarifConfiguration {
2378 level: "warning".to_string(),
2379 }),
2380 },
2381 SarifRule {
2382 id: "clone/type-2".to_string(),
2383 name: "ParameterizedClone".to_string(),
2384 short_description: SarifMessage {
2385 text: "Parameterized clone detected".to_string(),
2386 },
2387 full_description: Some(SarifMessage {
2388 text: "Type-2 clone: Code fragments with renamed identifiers or different literal values. The structure is identical. Consider refactoring to accept parameters.".to_string(),
2389 }),
2390 help_uri: None,
2391 default_configuration: Some(SarifConfiguration {
2392 level: "warning".to_string(),
2393 }),
2394 },
2395 SarifRule {
2396 id: "clone/type-3".to_string(),
2397 name: "GappedClone".to_string(),
2398 short_description: SarifMessage {
2399 text: "Similar code pattern detected".to_string(),
2400 },
2401 full_description: Some(SarifMessage {
2402 text: "Type-3 clone: Code fragments with similar structure but some statements added, removed, or modified. May indicate copy-paste programming.".to_string(),
2403 }),
2404 help_uri: None,
2405 default_configuration: Some(SarifConfiguration {
2406 level: "note".to_string(),
2407 }),
2408 },
2409 ];
2410
2411 let results: Vec<SarifResult> = report
2413 .clone_pairs
2414 .iter()
2415 .map(|pair| {
2416 let rule_id = clone_type_rule_id(pair.clone_type).to_string();
2417 let level = clone_type_level(pair.clone_type).to_string();
2418
2419 let primary_location = SarifLocation {
2421 physical_location: SarifPhysicalLocation {
2422 artifact_location: SarifArtifactLocation {
2423 uri: path_to_uri(&pair.fragment1.file, &report.root),
2424 uri_base_id: Some("%SRCROOT%".to_string()),
2425 },
2426 region: SarifRegion {
2427 start_line: pair.fragment1.start_line,
2428 end_line: Some(pair.fragment1.end_line),
2429 },
2430 },
2431 id: None,
2432 };
2433
2434 let related_location = SarifLocation {
2436 physical_location: SarifPhysicalLocation {
2437 artifact_location: SarifArtifactLocation {
2438 uri: path_to_uri(&pair.fragment2.file, &report.root),
2439 uri_base_id: Some("%SRCROOT%".to_string()),
2440 },
2441 region: SarifRegion {
2442 start_line: pair.fragment2.start_line,
2443 end_line: Some(pair.fragment2.end_line),
2444 },
2445 },
2446 id: Some(1),
2447 };
2448
2449 let message = format!(
2450 "{} ({:.0}% similar to {}:{})",
2451 clone_type_description(pair.clone_type),
2452 pair.similarity * 100.0,
2453 path_to_uri(&pair.fragment2.file, &report.root),
2454 pair.fragment2.start_line
2455 );
2456
2457 SarifResult {
2458 rule_id,
2459 level,
2460 message: SarifMessage { text: message },
2461 locations: vec![primary_location],
2462 related_locations: vec![related_location],
2463 partial_fingerprints: Some(SarifFingerprints {
2464 primary_location_line_hash: Some(format!(
2465 "{}:{}:{}:{}",
2466 path_to_uri(&pair.fragment1.file, &report.root),
2467 pair.fragment1.start_line,
2468 path_to_uri(&pair.fragment2.file, &report.root),
2469 pair.fragment2.start_line
2470 )),
2471 }),
2472 }
2473 })
2474 .collect();
2475
2476 SarifLog {
2477 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
2478 version: "2.1.0".to_string(),
2479 runs: vec![SarifRun {
2480 tool: SarifTool {
2481 driver: SarifDriver {
2482 name: "tldr".to_string(),
2483 version: env!("CARGO_PKG_VERSION").to_string(),
2484 information_uri: Some("https://github.com/anthropics/claude-code".to_string()),
2485 rules,
2486 },
2487 },
2488 results,
2489 invocations: Some(vec![SarifInvocation {
2490 execution_successful: true,
2491 }]),
2492 }],
2493 }
2494 }
2495}
2496
2497pub fn format_module_info_text(info: &tldr_core::types::ModuleInfo) -> String {
2527 let mut output = String::new();
2528
2529 output.push_str(&format!(
2531 "{} ({})\n",
2532 info.file_path.display().to_string().bold(),
2533 info.language.as_str().cyan()
2534 ));
2535
2536 if let Some(ref doc) = info.docstring {
2538 let truncated = if doc.len() > 80 {
2539 format!("{}...", truncate_at_char_boundary(doc, 77))
2540 } else {
2541 doc.clone()
2542 };
2543 output.push_str(&format!(" \"{}\"\n", truncated.dimmed()));
2544 }
2545
2546 output.push('\n');
2547
2548 if !info.imports.is_empty() {
2550 output.push_str(&format!("{} ({})\n", "Imports".bold(), info.imports.len()));
2551 output.push_str(&format!(
2552 " {}",
2553 format_imports_text(&info.imports)
2554 .lines()
2555 .collect::<Vec<_>>()
2556 .join("\n ")
2557 ));
2558 output.push('\n');
2559 }
2560
2561 if !info.functions.is_empty() {
2563 output.push_str(&format!(
2564 "{} ({})\n",
2565 "Functions".bold(),
2566 info.functions.len()
2567 ));
2568 for func in &info.functions {
2569 format_function_line(&mut output, func, " ");
2570 }
2571 output.push('\n');
2572 }
2573
2574 if !info.classes.is_empty() {
2576 output.push_str(&format!("{} ({})\n", "Classes".bold(), info.classes.len()));
2577 for class in &info.classes {
2578 let bases_str = if class.bases.is_empty() {
2580 String::new()
2581 } else {
2582 format!("({})", class.bases.join(", "))
2583 };
2584 output.push_str(&format!(
2585 " {}{} L{}\n",
2586 class.name.green(),
2587 bases_str,
2588 class.line_number
2589 ));
2590
2591 if let Some(ref doc) = class.docstring {
2593 let truncated = if doc.len() > 80 {
2594 format!("{}...", truncate_at_char_boundary(doc, 77))
2595 } else {
2596 doc.clone()
2597 };
2598 output.push_str(&format!(" \"{}\"\n", truncated.dimmed()));
2599 }
2600
2601 if !class.fields.is_empty() {
2603 let fields_summary: Vec<String> = class
2604 .fields
2605 .iter()
2606 .map(|f| {
2607 if let Some(ref ft) = f.field_type {
2608 format!("{}: {}", f.name, ft)
2609 } else {
2610 f.name.clone()
2611 }
2612 })
2613 .collect();
2614 output.push_str(&format!(" Fields: {}\n", fields_summary.join(", ")));
2615 }
2616
2617 if !class.methods.is_empty() {
2619 let methods_summary: Vec<String> = class
2620 .methods
2621 .iter()
2622 .map(|m| {
2623 let async_prefix = if m.is_async { "async " } else { "" };
2624 let params_str = m.params.join(", ");
2625 let ret = m
2626 .return_type
2627 .as_ref()
2628 .map(|r| format!(" -> {}", r))
2629 .unwrap_or_default();
2630 format!("{}{}({}){}", async_prefix, m.name, params_str, ret)
2631 })
2632 .collect();
2633 output.push_str(&format!(" Methods: {}\n", methods_summary.join(", ")));
2634 }
2635 }
2636 output.push('\n');
2637 }
2638
2639 if !info.constants.is_empty() {
2641 output.push_str(&format!(
2642 "{} ({})\n",
2643 "Constants".bold(),
2644 info.constants.len()
2645 ));
2646 for c in &info.constants {
2647 let type_str = c
2648 .field_type
2649 .as_ref()
2650 .map(|t| format!(": {}", t))
2651 .unwrap_or_default();
2652 let val_str = c
2653 .default_value
2654 .as_ref()
2655 .map(|v| format!(" = {}", v))
2656 .unwrap_or_default();
2657 output.push_str(&format!(
2658 " {}{}{} L{}\n",
2659 c.name.cyan(),
2660 type_str,
2661 val_str,
2662 c.line_number
2663 ));
2664 }
2665 output.push('\n');
2666 }
2667
2668 let total_edges: usize = info.call_graph.calls.values().map(|v| v.len()).sum();
2670 if total_edges > 0 {
2671 output.push_str(&format!(
2672 "{} ({} edges)\n",
2673 "Call Graph".bold(),
2674 total_edges
2675 ));
2676
2677 let mut callers: Vec<_> = info.call_graph.calls.keys().collect();
2679 callers.sort();
2680
2681 let mut shown = 0;
2682 for caller in callers {
2683 if shown >= 10 {
2684 let remaining = total_edges - shown;
2685 if remaining > 0 {
2686 output.push_str(&format!(" ... and {} more edges\n", remaining));
2687 }
2688 break;
2689 }
2690 if let Some(callees) = info.call_graph.calls.get(caller.as_str()) {
2691 for callee in callees {
2692 output.push_str(&format!(" {} -> {}\n", caller.dimmed(), callee.green()));
2693 shown += 1;
2694 if shown >= 10 {
2695 break;
2696 }
2697 }
2698 }
2699 }
2700 }
2701
2702 output
2703}
2704
2705fn format_function_line(output: &mut String, func: &tldr_core::types::FunctionInfo, indent: &str) {
2707 let async_prefix = if func.is_async { "async " } else { "" };
2708 let params_str = func.params.join(", ");
2709 let ret_str = func
2710 .return_type
2711 .as_ref()
2712 .map(|r| format!(" -> {}", r))
2713 .unwrap_or_default();
2714 output.push_str(&format!(
2715 "{}{}{}({}){} L{}\n",
2716 indent,
2717 async_prefix.cyan(),
2718 func.name.green(),
2719 params_str,
2720 ret_str,
2721 func.line_number
2722 ));
2723
2724 if let Some(ref doc) = func.docstring {
2726 let truncated = if doc.len() > 60 {
2727 format!("{}...", truncate_at_char_boundary(doc, 57))
2728 } else {
2729 doc.clone()
2730 };
2731 output.push_str(&format!("{} \"{}\"\n", indent, truncated.dimmed()));
2732 }
2733}
2734
2735pub fn format_clones_sarif(report: &tldr_core::analysis::ClonesReport) -> String {
2737 let sarif_log = sarif::format_clones_sarif(report);
2738 serde_json::to_string_pretty(&sarif_log).unwrap_or_else(|_| "{}".to_string())
2739}
2740
2741#[cfg(test)]
2746#[path = "output_tests.rs"]
2747mod output_tests;