1use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27use anyhow::Result;
28use clap::Args;
29use colored::Colorize;
30use tree_sitter::{Node, Parser};
31
32use tldr_core::analysis::clones::is_test_file;
33use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
34use tldr_core::ast::parser::ParserPool;
35use tldr_core::quality::coupling::{
36 analyze_coupling as core_analyze_coupling, compute_martin_metrics_from_deps,
37 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict, MartinMetricsReport,
38 MartinOptions,
39};
40use tldr_core::types::Language as TldrLanguage;
41
42use super::error::{PatternsError, PatternsResult};
43use super::types::{CouplingReport, CouplingVerdict, CrossCall, CrossCalls};
44use super::validation::{read_file_safe, validate_file_path, validate_file_path_in_project};
45use crate::output::{common_path_prefix, strip_prefix_display, OutputFormat};
46
47#[derive(Debug, Clone, Args)]
64pub struct CouplingArgs {
65 pub path_a: PathBuf,
67
68 pub path_b: Option<PathBuf>,
70
71 #[arg(long, default_value = "30")]
73 pub timeout: u64,
74
75 #[arg(long)]
77 pub project_root: Option<PathBuf>,
78
79 #[arg(long, short = 'n', default_value = "20")]
81 pub max_pairs: usize,
82
83 #[arg(long, default_value = "0")]
85 pub top: usize,
86
87 #[arg(long)]
89 pub cycles_only: bool,
90
91 #[arg(long)]
93 pub include_tests: bool,
94
95 #[arg(long, short = 'l')]
97 pub lang: Option<TldrLanguage>,
98}
99
100#[derive(Debug, Clone)]
106pub struct ModuleInfo {
107 pub path: PathBuf,
109 pub defined_names: HashSet<String>,
111 pub imports: HashMap<String, String>,
113 pub calls: Vec<(String, String, u32)>,
115 pub function_count: u32,
117}
118
119impl ModuleInfo {
120 fn new(path: PathBuf) -> Self {
121 Self {
122 path,
123 defined_names: HashSet::new(),
124 imports: HashMap::new(),
125 calls: Vec::new(),
126 function_count: 0,
127 }
128 }
129}
130
131struct LangConfig {
137 function_kinds: &'static [&'static str],
139 class_kinds: &'static [&'static str],
141 import_kinds: &'static [&'static str],
143 call_kinds: &'static [&'static str],
145 func_name_field: &'static str,
147 use_name_field: bool,
149 recurse_into_classes: bool,
151}
152
153fn lang_config_for(lang: TldrLanguage) -> LangConfig {
154 match lang {
155 TldrLanguage::Python => LangConfig {
156 function_kinds: &["function_definition", "async_function_definition"],
157 class_kinds: &["class_definition"],
158 import_kinds: &["import_statement", "import_from_statement"],
159 call_kinds: &["call"],
160 func_name_field: "name",
161 use_name_field: true,
162 recurse_into_classes: false,
163 },
164 TldrLanguage::Go => LangConfig {
165 function_kinds: &["function_declaration", "method_declaration"],
166 class_kinds: &["type_declaration"],
167 import_kinds: &["import_declaration"],
168 call_kinds: &["call_expression"],
169 func_name_field: "name",
170 use_name_field: true,
171 recurse_into_classes: false,
172 },
173 TldrLanguage::Rust => LangConfig {
174 function_kinds: &["function_item"],
175 class_kinds: &["struct_item", "enum_item", "trait_item", "impl_item"],
176 import_kinds: &["use_declaration"],
177 call_kinds: &["call_expression"],
178 func_name_field: "name",
179 use_name_field: true,
180 recurse_into_classes: true,
181 },
182 TldrLanguage::TypeScript | TldrLanguage::JavaScript => LangConfig {
183 function_kinds: &[
184 "function_declaration",
185 "method_definition",
186 "arrow_function",
187 ],
188 class_kinds: &["class_declaration"],
189 import_kinds: &["import_statement"],
190 call_kinds: &["call_expression"],
191 func_name_field: "name",
192 use_name_field: true,
193 recurse_into_classes: false,
194 },
195 TldrLanguage::Java => LangConfig {
196 function_kinds: &["method_declaration", "constructor_declaration"],
197 class_kinds: &["class_declaration", "interface_declaration"],
198 import_kinds: &["import_declaration"],
199 call_kinds: &["method_invocation"],
200 func_name_field: "name",
201 use_name_field: true,
202 recurse_into_classes: true,
203 },
204 TldrLanguage::C => LangConfig {
205 function_kinds: &["function_definition"],
206 class_kinds: &["struct_specifier", "enum_specifier"],
207 import_kinds: &["preproc_include"],
208 call_kinds: &["call_expression"],
209 func_name_field: "declarator",
210 use_name_field: true,
211 recurse_into_classes: false,
212 },
213 TldrLanguage::Cpp => LangConfig {
214 function_kinds: &["function_definition"],
215 class_kinds: &["class_specifier", "struct_specifier", "enum_specifier"],
216 import_kinds: &["preproc_include"],
217 call_kinds: &["call_expression"],
218 func_name_field: "declarator",
219 use_name_field: true,
220 recurse_into_classes: true,
221 },
222 TldrLanguage::Ruby => LangConfig {
223 function_kinds: &["method", "singleton_method"],
224 class_kinds: &["class", "module"],
225 import_kinds: &[], call_kinds: &["call", "command"],
227 func_name_field: "name",
228 use_name_field: true,
229 recurse_into_classes: true,
230 },
231 TldrLanguage::CSharp => LangConfig {
232 function_kinds: &["method_declaration", "constructor_declaration"],
233 class_kinds: &[
234 "class_declaration",
235 "interface_declaration",
236 "struct_declaration",
237 ],
238 import_kinds: &["using_directive"],
239 call_kinds: &["invocation_expression"],
240 func_name_field: "name",
241 use_name_field: true,
242 recurse_into_classes: true,
243 },
244 TldrLanguage::Php => LangConfig {
245 function_kinds: &["function_definition", "method_declaration"],
246 class_kinds: &["class_declaration", "interface_declaration"],
247 import_kinds: &["namespace_use_declaration"],
248 call_kinds: &["function_call_expression", "member_call_expression"],
249 func_name_field: "name",
250 use_name_field: true,
251 recurse_into_classes: true,
252 },
253 TldrLanguage::Scala => LangConfig {
254 function_kinds: &["function_definition"],
255 class_kinds: &["class_definition", "object_definition", "trait_definition"],
256 import_kinds: &["import_declaration"],
257 call_kinds: &["call_expression"],
258 func_name_field: "name",
259 use_name_field: true,
260 recurse_into_classes: true,
261 },
262 TldrLanguage::Elixir => LangConfig {
263 function_kinds: &["call"], class_kinds: &[],
265 import_kinds: &[], call_kinds: &["call"],
267 func_name_field: "",
268 use_name_field: false,
269 recurse_into_classes: false,
270 },
271 TldrLanguage::Lua | TldrLanguage::Luau => LangConfig {
272 function_kinds: &[
273 "function_declaration",
274 "local_function_declaration_statement",
275 ],
276 class_kinds: &[],
277 import_kinds: &[], call_kinds: &["function_call"],
279 func_name_field: "name",
280 use_name_field: true,
281 recurse_into_classes: false,
282 },
283 TldrLanguage::Ocaml => LangConfig {
284 function_kinds: &["let_binding", "value_definition"],
285 class_kinds: &["type_definition", "module_definition"],
286 import_kinds: &["open_statement"],
287 call_kinds: &["application"],
288 func_name_field: "",
289 use_name_field: false,
290 recurse_into_classes: false,
291 },
292 _ => LangConfig {
294 function_kinds: &["function_definition"],
295 class_kinds: &["class_definition"],
296 import_kinds: &["import_statement"],
297 call_kinds: &["call_expression"],
298 func_name_field: "name",
299 use_name_field: true,
300 recurse_into_classes: false,
301 },
302 }
303}
304
305fn detect_language(path: &Path) -> PatternsResult<TldrLanguage> {
307 TldrLanguage::from_path(path).ok_or_else(|| {
308 PatternsError::parse_error(
309 path,
310 format!(
311 "Unsupported file extension: {}",
312 path.extension()
313 .and_then(|e| e.to_str())
314 .unwrap_or("(none)")
315 ),
316 )
317 })
318}
319
320pub fn extract_module_info(path: &PathBuf, source: &str) -> PatternsResult<ModuleInfo> {
332 let lang = detect_language(path)?;
333
334 let ts_lang = ParserPool::get_ts_language(lang).ok_or_else(|| {
335 PatternsError::parse_error(path, format!("No tree-sitter grammar for {:?}", lang))
336 })?;
337
338 let mut parser = Parser::new();
339 parser
340 .set_language(&ts_lang)
341 .map_err(|e| PatternsError::parse_error(path, format!("Failed to set language: {}", e)))?;
342
343 let tree = parser
344 .parse(source, None)
345 .ok_or_else(|| PatternsError::parse_error(path, "Failed to parse source"))?;
346
347 let root = tree.root_node();
348 let config = lang_config_for(lang);
349 let mut info = ModuleInfo::new(path.clone());
350
351 extract_top_level_generic(&root, source, &mut info, &config, lang)?;
353
354 if matches!(
359 lang,
360 TldrLanguage::Go
361 | TldrLanguage::Java
362 | TldrLanguage::CSharp
363 | TldrLanguage::Php
364 | TldrLanguage::Scala
365 ) {
366 enrich_imports_from_qualified_calls(&mut info);
367 }
368
369 Ok(info)
370}
371
372fn enrich_imports_from_qualified_calls(info: &mut ModuleInfo) {
378 let mut new_imports: Vec<(String, String)> = Vec::new();
380
381 for (_caller, callee, _line) in &info.calls {
382 if info.imports.contains_key(callee) {
384 continue;
385 }
386 new_imports.push((callee.clone(), callee.clone()));
390 }
391
392 for (name, module) in new_imports {
393 info.imports.entry(name).or_insert(module);
394 }
395}
396
397fn extract_top_level_generic(
399 root: &Node,
400 source: &str,
401 info: &mut ModuleInfo,
402 config: &LangConfig,
403 lang: TldrLanguage,
404) -> PatternsResult<()> {
405 extract_definitions_recursive(root, source, info, config, lang, 0);
406 Ok(())
407}
408
409fn extract_definitions_recursive(
413 node: &Node,
414 source: &str,
415 info: &mut ModuleInfo,
416 config: &LangConfig,
417 lang: TldrLanguage,
418 depth: u32,
419) {
420 let mut cursor = node.walk();
421
422 for child in node.children(&mut cursor) {
423 let kind = child.kind();
424
425 if config.function_kinds.contains(&kind) {
427 if lang == TldrLanguage::Elixir && kind == "call" {
429 if let Some(name) = extract_elixir_def_name(&child, source) {
430 info.defined_names.insert(name.clone());
431 info.function_count += 1;
432 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
433 }
434 continue;
435 }
436
437 if let Some(name) = get_name_generic(&child, source, config, lang) {
438 info.defined_names.insert(name.clone());
439 info.function_count += 1;
440 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
441 }
442 }
443 else if config.class_kinds.contains(&kind) {
445 if let Some(name) = get_name_generic(&child, source, config, lang) {
446 info.defined_names.insert(name);
447 }
448 if config.recurse_into_classes && depth < 3 {
450 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
451 }
452 }
453 else if config.import_kinds.contains(&kind) {
455 extract_imports_generic(&child, source, &mut info.imports, lang);
456 }
457 else if lang == TldrLanguage::Ruby && (kind == "call" || kind == "command") {
459 extract_ruby_require(&child, source, &mut info.imports);
460 }
461 else if is_body_container(kind, lang) {
463 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
464 }
465 }
466}
467
468fn is_body_container(kind: &str, lang: TldrLanguage) -> bool {
470 match lang {
471 TldrLanguage::Java => matches!(kind, "class_body" | "program"),
472 TldrLanguage::CSharp => matches!(
473 kind,
474 "namespace_declaration"
475 | "file_scoped_namespace_declaration"
476 | "declaration_list"
477 | "class_body"
478 ),
479 TldrLanguage::Php => matches!(kind, "declaration_list" | "class_body" | "program"),
480 TldrLanguage::Scala => matches!(kind, "template_body"),
481 TldrLanguage::Cpp => matches!(kind, "declaration_list"),
482 TldrLanguage::Ruby => matches!(kind, "body_statement" | "program"),
483 _ => false,
484 }
485}
486
487fn get_name_generic(
489 node: &Node,
490 source: &str,
491 config: &LangConfig,
492 _lang: TldrLanguage,
493) -> Option<String> {
494 if config.use_name_field && !config.func_name_field.is_empty() {
496 if let Some(name_node) = node.child_by_field_name(config.func_name_field) {
497 return Some(extract_leaf_identifier(&name_node, source));
499 }
500 }
501
502 let mut cursor = node.walk();
504 for child in node.children(&mut cursor) {
505 if child.kind() == "identifier" || child.kind() == "name" {
506 return Some(node_text(&child, source));
507 }
508 }
509
510 None
511}
512
513fn extract_leaf_identifier(node: &Node, source: &str) -> String {
517 if node.kind() == "identifier" || node.kind() == "name" || node.child_count() == 0 {
518 return node_text(node, source);
519 }
520
521 let mut cursor = node.walk();
523 for child in node.children(&mut cursor) {
524 if child.kind() == "identifier" || child.kind() == "name" {
525 return node_text(&child, source);
526 }
527 let result = extract_leaf_identifier(&child, source);
529 if !result.is_empty() {
530 return result;
531 }
532 }
533
534 node_text(node, source)
535}
536
537fn extract_elixir_def_name(node: &Node, source: &str) -> Option<String> {
539 let mut cursor = node.walk();
542 for child in node.children(&mut cursor) {
543 let text = node_text(&child, source);
544 if text == "def" || text == "defp" {
545 if let Some(args) = child.next_sibling() {
547 return get_first_identifier(&args, source);
548 }
549 }
550 }
551 None
552}
553
554fn get_first_identifier(node: &Node, source: &str) -> Option<String> {
556 if node.kind() == "identifier" || node.kind() == "atom" {
557 return Some(node_text(node, source));
558 }
559 let mut cursor = node.walk();
560 for child in node.children(&mut cursor) {
561 if let Some(id) = get_first_identifier(&child, source) {
562 return Some(id);
563 }
564 }
565 None
566}
567
568fn extract_imports_generic(
574 node: &Node,
575 source: &str,
576 imports: &mut HashMap<String, String>,
577 lang: TldrLanguage,
578) {
579 match lang {
580 TldrLanguage::Python => extract_python_imports(node, source, imports),
581 TldrLanguage::Go => extract_go_imports(node, source, imports),
582 TldrLanguage::Rust => extract_rust_imports(node, source, imports),
583 TldrLanguage::TypeScript | TldrLanguage::JavaScript => {
584 extract_ts_imports(node, source, imports)
585 }
586 TldrLanguage::Java => extract_java_imports(node, source, imports),
587 TldrLanguage::C | TldrLanguage::Cpp => extract_c_imports(node, source, imports),
588 TldrLanguage::CSharp => extract_csharp_imports(node, source, imports),
589 TldrLanguage::Php => extract_php_imports(node, source, imports),
590 TldrLanguage::Scala => extract_scala_imports(node, source, imports),
591 TldrLanguage::Ocaml => extract_ocaml_imports(node, source, imports),
592 _ => extract_fallback_imports(node, source, imports),
595 }
596}
597
598fn extract_python_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
600 let kind = node.kind();
601 if kind == "import_statement" {
602 let mut cursor = node.walk();
603 for child in node.children(&mut cursor) {
604 if child.kind() == "dotted_name" {
605 let module_name = node_text(&child, source);
606 imports.insert(module_name.clone(), module_name);
607 } else if child.kind() == "aliased_import" {
608 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
609 imports.insert(alias, name);
610 }
611 }
612 }
613 } else if kind == "import_from_statement" {
614 let mut module_name = String::new();
615 let mut found_import_keyword = false;
616
617 let mut cursor = node.walk();
619 for child in node.children(&mut cursor) {
620 if child.kind() == "import" {
621 found_import_keyword = true;
622 continue;
623 }
624 if !found_import_keyword {
625 match child.kind() {
626 "dotted_name" | "relative_import" | "import_prefix" => {
627 module_name = node_text(&child, source);
628 }
629 _ => {}
630 }
631 }
632 }
633
634 let mut cursor2 = node.walk();
636 found_import_keyword = false;
637 for child in node.children(&mut cursor2) {
638 if child.kind() == "import" {
639 found_import_keyword = true;
640 continue;
641 }
642 if !found_import_keyword {
643 continue;
644 }
645 match child.kind() {
646 "dotted_name" | "identifier" => {
647 let name = node_text(&child, source);
648 imports.insert(name, module_name.clone());
649 }
650 "aliased_import" => {
651 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
652 imports.insert(alias, module_name.clone());
653 imports.insert(name, module_name.clone());
654 }
655 }
656 "wildcard_import" => {
657 imports.insert("*".to_string(), module_name.clone());
658 }
659 _ => {
660 extract_import_names_recursive(&child, source, &module_name, imports);
661 }
662 }
663 }
664 }
665}
666
667fn extract_import_names_recursive(
669 node: &Node,
670 source: &str,
671 module_name: &str,
672 imports: &mut HashMap<String, String>,
673) {
674 match node.kind() {
675 "dotted_name" | "identifier" => {
676 let name = node_text(node, source);
677 imports.insert(name, module_name.to_string());
678 }
679 "aliased_import" => {
680 if let (Some(name), Some(alias)) = extract_aliased_import(node, source) {
681 imports.insert(alias, module_name.to_string());
682 imports.insert(name, module_name.to_string());
683 }
684 }
685 _ => {
686 let mut cursor = node.walk();
687 for child in node.children(&mut cursor) {
688 extract_import_names_recursive(&child, source, module_name, imports);
689 }
690 }
691 }
692}
693
694fn extract_aliased_import(node: &Node, source: &str) -> (Option<String>, Option<String>) {
696 let mut name = None;
697 let mut alias = None;
698 let mut cursor = node.walk();
699
700 for child in node.children(&mut cursor) {
701 match child.kind() {
702 "dotted_name" | "identifier" => {
703 if name.is_none() {
704 name = Some(node_text(&child, source));
705 } else {
706 alias = Some(node_text(&child, source));
707 }
708 }
709 _ => {}
710 }
711 }
712
713 (name, alias)
714}
715
716fn extract_go_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
718 let mut stack = vec![*node];
720 while let Some(n) = stack.pop() {
721 if n.kind() == "import_spec" {
722 let path_node = n.child_by_field_name("path");
724 let name_node = n.child_by_field_name("name");
725
726 if let Some(path) = path_node {
727 let raw = node_text(&path, source);
728 let module_path = raw.trim_matches('"').to_string();
729 let short_name = if let Some(alias) = name_node {
731 node_text(&alias, source)
732 } else {
733 module_path
734 .rsplit('/')
735 .next()
736 .unwrap_or(&module_path)
737 .to_string()
738 };
739 imports.insert(short_name, module_path);
740 }
741 } else {
742 let mut cursor = n.walk();
743 for child in n.children(&mut cursor) {
744 stack.push(child);
745 }
746 }
747 }
748}
749
750fn extract_rust_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
752 let text = node_text(node, source);
754 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
756
757 if let Some(last) = trimmed.rsplit("::").next() {
759 if last.starts_with('{') {
760 let base = trimmed.rsplit_once("::").map(|x| x.0).unwrap_or("");
762 let items = last.trim_matches(|c| c == '{' || c == '}');
763 for item in items.split(',') {
764 let item = item.trim();
765 if !item.is_empty() {
766 imports.insert(item.to_string(), base.to_string());
767 }
768 }
769 } else {
770 imports.insert(last.to_string(), trimmed.to_string());
771 }
772 }
773}
774
775fn extract_ts_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
777 let mut module_path = String::new();
779 let mut cursor = node.walk();
780
781 if let Some(src) = node.child_by_field_name("source") {
783 let raw = node_text(&src, source);
784 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
785 } else {
786 for child in node.children(&mut cursor) {
788 if child.kind() == "string" {
789 let raw = node_text(&child, source);
790 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
791 }
792 }
793 }
794
795 let mut cursor2 = node.walk();
797 for child in node.children(&mut cursor2) {
798 match child.kind() {
799 "import_clause" | "named_imports" | "import_specifier" => {
800 collect_identifiers_recursive(&child, source, &module_path, imports);
801 }
802 "namespace_import" => {
803 if let Some(name) = child.child_by_field_name("name") {
805 imports.insert(node_text(&name, source), module_path.clone());
806 } else {
807 let mut inner = child.walk();
809 let mut last_id = None;
810 for c in child.children(&mut inner) {
811 if c.kind() == "identifier" {
812 last_id = Some(node_text(&c, source));
813 }
814 }
815 if let Some(id) = last_id {
816 imports.insert(id, module_path.clone());
817 }
818 }
819 }
820 "identifier" => {
821 imports.insert(node_text(&child, source), module_path.clone());
822 }
823 _ => {}
824 }
825 }
826}
827
828fn extract_java_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
830 let text = node_text(node, source);
832 let trimmed = text
833 .trim_start_matches("import ")
834 .trim_start_matches("static ")
835 .trim_end_matches(';')
836 .trim();
837
838 if let Some(last) = trimmed.rsplit('.').next() {
839 imports.insert(last.to_string(), trimmed.to_string());
840 }
841}
842
843fn extract_c_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
845 let mut cursor = node.walk();
847 for child in node.children(&mut cursor) {
848 let kind = child.kind();
849 if kind == "system_lib_string" || kind == "string_literal" || kind == "string_content" {
850 let raw = node_text(&child, source);
851 let header = raw
852 .trim_matches(|c| c == '<' || c == '>' || c == '"')
853 .to_string();
854 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
856 imports.insert(short, header);
857 }
858 }
859
860 if let Some(path) = node.child_by_field_name("path") {
862 let raw = node_text(&path, source);
863 let header = raw
864 .trim_matches(|c| c == '<' || c == '>' || c == '"')
865 .to_string();
866 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
867 imports.insert(short, header);
868 }
869}
870
871fn extract_csharp_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
873 let text = node_text(node, source);
874 let trimmed = text
875 .trim_start_matches("using ")
876 .trim_start_matches("static ")
877 .trim_end_matches(';')
878 .trim();
879
880 if let Some(last) = trimmed.rsplit('.').next() {
881 imports.insert(last.to_string(), trimmed.to_string());
882 }
883 if !trimmed.is_empty() {
885 imports.insert(trimmed.to_string(), trimmed.to_string());
886 }
887}
888
889fn extract_php_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
891 let text = node_text(node, source);
892 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
893
894 if trimmed.contains('{') {
896 if let Some((base, group)) = trimmed.split_once('{') {
897 let base = base.trim_end_matches('\\');
898 let items = group.trim_end_matches('}');
899 for item in items.split(',') {
900 let item = item.trim();
901 if !item.is_empty() {
902 imports.insert(item.to_string(), format!("{}\\{}", base, item));
903 }
904 }
905 }
906 } else if let Some(last) = trimmed.rsplit('\\').next() {
907 imports.insert(last.to_string(), trimmed.to_string());
908 }
909}
910
911fn extract_scala_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
913 let text = node_text(node, source);
914 let trimmed = text.trim_start_matches("import ").trim();
915
916 if let Some(last) = trimmed.rsplit('.').next() {
917 imports.insert(last.to_string(), trimmed.to_string());
918 }
919}
920
921fn extract_ocaml_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
923 let text = node_text(node, source);
924 let trimmed = text.trim_start_matches("open ").trim();
925 if !trimmed.is_empty() {
926 imports.insert(trimmed.to_string(), trimmed.to_string());
927 }
928}
929
930fn extract_fallback_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
932 let text = node_text(node, source).trim().to_string();
933 if !text.is_empty() {
934 imports.insert(text.clone(), text);
935 }
936}
937
938fn extract_ruby_require(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
940 let mut cursor = node.walk();
942 let mut method_name = String::new();
943
944 for child in node.children(&mut cursor) {
945 match child.kind() {
946 "identifier" | "constant" => {
947 let text = node_text(&child, source);
948 if text == "require" || text == "require_relative" {
949 method_name = text;
950 }
951 }
952 "argument_list" | "string" | "string_content" => {
953 if !method_name.is_empty() {
954 let raw = node_text(&child, source);
955 let module = raw
956 .trim_matches(|c: char| c == '\'' || c == '"' || c == '(' || c == ')')
957 .to_string();
958 if !module.is_empty() {
959 let short = module.rsplit('/').next().unwrap_or(&module).to_string();
960 imports.insert(short, module);
961 }
962 return;
963 }
964 }
965 _ => {
966 if !method_name.is_empty() {
968 let mut inner = child.walk();
969 for grandchild in child.children(&mut inner) {
970 if grandchild.kind() == "string" || grandchild.kind() == "string_content" {
971 let raw = node_text(&grandchild, source);
972 let module = raw
973 .trim_matches(|c: char| c == '\'' || c == '"')
974 .to_string();
975 if !module.is_empty() {
976 let short =
977 module.rsplit('/').next().unwrap_or(&module).to_string();
978 imports.insert(short, module);
979 }
980 return;
981 }
982 }
983 }
984 }
985 }
986 }
987}
988
989fn collect_identifiers_recursive(
991 node: &Node,
992 source: &str,
993 module_path: &str,
994 imports: &mut HashMap<String, String>,
995) {
996 if node.kind() == "identifier" {
997 imports.insert(node_text(node, source), module_path.to_string());
998 return;
999 }
1000 let mut cursor = node.walk();
1001 for child in node.children(&mut cursor) {
1002 collect_identifiers_recursive(&child, source, module_path, imports);
1003 }
1004}
1005
1006fn extract_calls_generic(
1012 func_node: &Node,
1013 source: &str,
1014 caller_name: &str,
1015 calls: &mut Vec<(String, String, u32)>,
1016 config: &LangConfig,
1017 lang: TldrLanguage,
1018) {
1019 let mut stack = vec![*func_node];
1020
1021 while let Some(node) = stack.pop() {
1022 if config.call_kinds.contains(&node.kind()) {
1023 if let Some(callee) = extract_callee_generic(&node, source, lang) {
1024 let line = node.start_position().row as u32 + 1;
1025 calls.push((caller_name.to_string(), callee, line));
1026 }
1027 }
1028
1029 let mut cursor = node.walk();
1031 for child in node.children(&mut cursor) {
1032 stack.push(child);
1033 }
1034 }
1035}
1036
1037fn extract_callee_generic(call_node: &Node, source: &str, lang: TldrLanguage) -> Option<String> {
1045 match lang {
1046 TldrLanguage::Java => {
1047 if let Some(name) = call_node.child_by_field_name("name") {
1049 return Some(node_text(&name, source));
1050 }
1051 }
1052 TldrLanguage::Go => {
1053 if let Some(func) = call_node.child_by_field_name("function") {
1055 match func.kind() {
1056 "identifier" => return Some(node_text(&func, source)),
1057 "selector_expression" => {
1058 if let Some(field) = func.child_by_field_name("field") {
1060 return Some(node_text(&field, source));
1061 }
1062 }
1063 _ => return Some(node_text(&func, source)),
1064 }
1065 }
1066 }
1067 TldrLanguage::Php => {
1068 if let Some(func) = call_node.child_by_field_name("function") {
1070 return Some(extract_leaf_identifier(&func, source));
1071 }
1072 if let Some(name) = call_node.child_by_field_name("name") {
1073 return Some(node_text(&name, source));
1074 }
1075 }
1076 TldrLanguage::CSharp => {
1077 if let Some(func) = call_node.child_by_field_name("function") {
1079 return Some(extract_last_identifier(&func, source));
1080 }
1081 let mut cursor = call_node.walk();
1083 for child in call_node.children(&mut cursor) {
1084 if child.kind() == "member_access_expression" {
1085 if let Some(name) = child.child_by_field_name("name") {
1086 return Some(node_text(&name, source));
1087 }
1088 }
1089 if child.kind() == "identifier" {
1090 return Some(node_text(&child, source));
1091 }
1092 }
1093 }
1094 _ => {}
1095 }
1096
1097 let mut cursor = call_node.walk();
1099 for child in call_node.children(&mut cursor) {
1100 match child.kind() {
1101 "identifier" | "name" => {
1102 return Some(node_text(&child, source));
1103 }
1104 "attribute" | "member_expression" | "field_expression" | "selector_expression" => {
1105 return Some(extract_last_identifier(&child, source));
1107 }
1108 "scoped_identifier" | "qualified_identifier" => {
1109 return Some(extract_last_identifier(&child, source));
1111 }
1112 _ => {}
1113 }
1114 }
1115 None
1116}
1117
1118fn extract_last_identifier(node: &Node, source: &str) -> String {
1120 let mut last_id = node_text(node, source);
1121 let mut cursor = node.walk();
1122 for child in node.children(&mut cursor) {
1123 if child.kind() == "identifier"
1124 || child.kind() == "name"
1125 || child.kind() == "field_identifier"
1126 || child.kind() == "property_identifier"
1127 {
1128 last_id = node_text(&child, source);
1129 }
1130 }
1131 last_id
1132}
1133
1134fn node_text(node: &Node, source: &str) -> String {
1136 source[node.byte_range()].to_string()
1137}
1138
1139pub fn find_cross_calls(caller: &ModuleInfo, callee: &ModuleInfo) -> CrossCalls {
1150 let mut calls = Vec::new();
1151
1152 for (caller_func, callee_name, line) in &caller.calls {
1153 if caller.imports.contains_key(callee_name) && callee.defined_names.contains(callee_name) {
1157 calls.push(CrossCall {
1158 caller: caller_func.clone(),
1159 callee: callee_name.clone(),
1160 line: *line,
1161 });
1162 }
1163 }
1164
1165 let count = calls.len() as u32;
1166 CrossCalls { calls, count }
1167}
1168
1169pub fn compute_coupling_score(a_to_b: u32, b_to_a: u32, funcs_a: u32, funcs_b: u32) -> f64 {
1184 let total_funcs = funcs_a.saturating_add(funcs_b);
1185 if total_funcs == 0 {
1186 return 0.0;
1187 }
1188
1189 let cross_calls = a_to_b.saturating_add(b_to_a);
1190 let denominator = (total_funcs as f64) * 2.0;
1191
1192 (cross_calls as f64 / denominator).min(1.0)
1193}
1194
1195pub fn format_martin_text(report: &tldr_core::quality::coupling::MartinMetricsReport) -> String {
1204 let mut output = String::new();
1205
1206 output.push_str("Martin Coupling Metrics (project-wide)\n\n");
1207
1208 if report.metrics.is_empty() {
1209 output.push_str("No modules found.\n");
1210 return output;
1211 }
1212
1213 let max_path_len = report
1215 .metrics
1216 .iter()
1217 .map(|m| m.module.to_string_lossy().len())
1218 .max()
1219 .unwrap_or(6)
1220 .clamp(6, 40);
1221
1222 output.push_str(&format!(
1224 " {:<width$} | {:>2} | {:>2} | {:>6} | Cycle?\n",
1225 "Module",
1226 "Ca",
1227 "Ce",
1228 "I",
1229 width = max_path_len,
1230 ));
1231 output.push_str(&format!(
1232 "-{}-+----+----+--------+-------\n",
1233 "-".repeat(max_path_len),
1234 ));
1235
1236 for m in &report.metrics {
1238 let path_display = m.module.to_string_lossy();
1239 let truncated_path = if path_display.len() > max_path_len {
1240 format!(
1241 "...{}",
1242 &path_display[path_display.len() - (max_path_len - 3)..]
1243 )
1244 } else {
1245 path_display.to_string()
1246 };
1247
1248 let cycle_str = if m.in_cycle { "yes" } else { "--" };
1249
1250 output.push_str(&format!(
1251 " {:<width$} | {:>2} | {:>2} | {:.2} | {}\n",
1252 truncated_path,
1253 m.ca,
1254 m.ce,
1255 m.instability,
1256 cycle_str,
1257 width = max_path_len,
1258 ));
1259 }
1260
1261 output.push_str(&format!(
1263 "\nSummary: {} modules, {} cycles detected, avg instability: {:.2}\n",
1264 report.modules_analyzed, report.summary.total_cycles, report.summary.avg_instability,
1265 ));
1266
1267 if !report.cycles.is_empty() {
1269 output.push_str("\nCycles:\n");
1270 for (i, cycle) in report.cycles.iter().enumerate() {
1271 let path_strs: Vec<String> = cycle
1272 .path
1273 .iter()
1274 .map(|p| p.to_string_lossy().to_string())
1275 .collect();
1276 output.push_str(&format!(
1277 " {}. {} (length {})\n",
1278 i + 1,
1279 path_strs.join(" -> "),
1280 cycle.length,
1281 ));
1282 }
1283 }
1284
1285 output
1286}
1287
1288pub fn format_coupling_text(report: &CouplingReport) -> String {
1290 let mut lines = Vec::new();
1291
1292 lines.push(format!(
1293 "Coupling Analysis: {} <-> {}",
1294 report.path_a, report.path_b
1295 ));
1296 lines.push(String::new());
1297 lines.push(format!(
1298 "Score: {:.2} ({})",
1299 report.coupling_score, report.verdict
1300 ));
1301 lines.push(format!("Total cross-module calls: {}", report.total_calls));
1302 lines.push(String::new());
1303
1304 lines.push(format!(
1306 "Calls from {} to {}:",
1307 report.path_a, report.path_b
1308 ));
1309 if report.a_to_b.calls.is_empty() {
1310 lines.push(" (none)".to_string());
1311 } else {
1312 for call in &report.a_to_b.calls {
1313 lines.push(format!(
1314 " {} -> {} (line {})",
1315 call.caller, call.callee, call.line
1316 ));
1317 }
1318 }
1319 lines.push(String::new());
1320
1321 lines.push(format!(
1323 "Calls from {} to {}:",
1324 report.path_b, report.path_a
1325 ));
1326 if report.b_to_a.calls.is_empty() {
1327 lines.push(" (none)".to_string());
1328 } else {
1329 for call in &report.b_to_a.calls {
1330 lines.push(format!(
1331 " {} -> {} (line {})",
1332 call.caller, call.callee, call.line
1333 ));
1334 }
1335 }
1336
1337 lines.join("\n")
1338}
1339
1340pub fn run(args: CouplingArgs, format: OutputFormat) -> Result<()> {
1352 match args.path_b {
1354 Some(ref _path_b) => run_pair_mode(&args, format),
1355 None if args.path_a.is_dir() => run_project_mode(&args, format),
1356 None => {
1357 Err(anyhow::anyhow!(
1359 "For pair mode, provide two file paths: tldr coupling <file_a> <file_b>\n\
1360 For project-wide mode, provide a directory: tldr coupling <directory>"
1361 ))
1362 }
1363 }
1364}
1365
1366fn run_pair_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1368 let start = Instant::now();
1369 let timeout = Duration::from_secs(args.timeout);
1370
1371 let path_b_ref = args.path_b.as_ref().expect("pair mode requires path_b");
1372
1373 let path_a = if let Some(ref root) = args.project_root {
1375 validate_file_path_in_project(&args.path_a, root)?
1376 } else {
1377 validate_file_path(&args.path_a)?
1378 };
1379
1380 let path_b = if let Some(ref root) = args.project_root {
1381 validate_file_path_in_project(path_b_ref, root)?
1382 } else {
1383 validate_file_path(path_b_ref)?
1384 };
1385
1386 if start.elapsed() > timeout {
1388 return Err(PatternsError::Timeout {
1389 timeout_secs: args.timeout,
1390 }
1391 .into());
1392 }
1393
1394 let source_a = read_file_safe(&path_a)?;
1396 let source_b = read_file_safe(&path_b)?;
1397
1398 if start.elapsed() > timeout {
1400 return Err(PatternsError::Timeout {
1401 timeout_secs: args.timeout,
1402 }
1403 .into());
1404 }
1405
1406 if path_a == path_b {
1408 let report = CouplingReport {
1409 path_a: path_a.to_string_lossy().to_string(),
1410 path_b: path_b.to_string_lossy().to_string(),
1411 a_to_b: CrossCalls::default(),
1412 b_to_a: CrossCalls::default(),
1413 total_calls: 0,
1414 coupling_score: 1.0,
1415 verdict: CouplingVerdict::VeryHigh,
1416 };
1417
1418 output_pair_report(&report, format)?;
1419 return Ok(());
1420 }
1421
1422 let info_a = extract_module_info(&path_a, &source_a)?;
1424 let info_b = extract_module_info(&path_b, &source_b)?;
1425
1426 if start.elapsed() > timeout {
1428 return Err(PatternsError::Timeout {
1429 timeout_secs: args.timeout,
1430 }
1431 .into());
1432 }
1433
1434 let a_to_b = find_cross_calls(&info_a, &info_b);
1436 let b_to_a = find_cross_calls(&info_b, &info_a);
1437
1438 let total_calls = a_to_b.count.saturating_add(b_to_a.count);
1440 let coupling_score = compute_coupling_score(
1441 a_to_b.count,
1442 b_to_a.count,
1443 info_a.function_count,
1444 info_b.function_count,
1445 );
1446 let verdict = CouplingVerdict::from_score(coupling_score);
1447
1448 let report = CouplingReport {
1450 path_a: path_a.to_string_lossy().to_string(),
1451 path_b: path_b.to_string_lossy().to_string(),
1452 a_to_b,
1453 b_to_a,
1454 total_calls,
1455 coupling_score,
1456 verdict,
1457 };
1458
1459 output_pair_report(&report, format)?;
1460
1461 Ok(())
1462}
1463
1464fn run_project_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1466 let mut pairwise_report = core_analyze_coupling(&args.path_a, None, Some(args.max_pairs))
1468 .map_err(|e| anyhow::anyhow!("coupling analysis failed: {}", e))?;
1469
1470 if !args.include_tests {
1472 pairwise_report
1473 .top_pairs
1474 .retain(|pair| !is_test_file(&pair.source) && !is_test_file(&pair.target));
1475 }
1476
1477 let martin_options = MartinOptions {
1479 top: args.top,
1480 cycles_only: args.cycles_only,
1481 };
1482 let mut martin_report = match analyze_dependencies(&args.path_a, &DepsOptions::default()) {
1483 Ok(deps_report) => compute_martin_metrics_from_deps(&deps_report, &martin_options),
1484 Err(_) => MartinMetricsReport::default(), };
1486
1487 if !args.include_tests {
1489 let pre_count = martin_report.metrics.len();
1490 martin_report.metrics.retain(|m| !is_test_file(&m.module));
1491 martin_report.modules_analyzed = martin_report.metrics.len();
1492
1493 if martin_report.metrics.len() < pre_count {
1495 if martin_report.metrics.is_empty() {
1496 martin_report.summary.avg_instability = 0.0;
1497 martin_report.summary.most_stable = None;
1498 martin_report.summary.most_unstable = None;
1499 } else {
1500 let sum: f64 = martin_report.metrics.iter().map(|m| m.instability).sum();
1501 martin_report.summary.avg_instability = sum / martin_report.metrics.len() as f64;
1502 martin_report.summary.most_stable = martin_report
1503 .metrics
1504 .iter()
1505 .min_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1506 .map(|m| m.module.clone());
1507 martin_report.summary.most_unstable = martin_report
1508 .metrics
1509 .iter()
1510 .max_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1511 .map(|m| m.module.clone());
1512 }
1513 martin_report
1515 .cycles
1516 .retain(|cycle| cycle.path.iter().all(|m| !is_test_file(m)));
1517 martin_report.summary.total_cycles = martin_report.cycles.len();
1518 }
1519 }
1520
1521 output_project_report_with_martin(&pairwise_report, &martin_report, format)?;
1522 Ok(())
1523}
1524
1525fn output_project_report_with_martin(
1527 pairwise_report: &CoreCouplingReport,
1528 martin_report: &MartinMetricsReport,
1529 format: OutputFormat,
1530) -> Result<()> {
1531 match format {
1532 OutputFormat::Text => {
1533 println!("{}", format_martin_text(martin_report));
1535 if !pairwise_report.top_pairs.is_empty() {
1536 println!("{}", format_coupling_project_text(pairwise_report));
1537 }
1538 }
1539 OutputFormat::Compact => {
1540 let combined = serde_json::json!({
1541 "martin_metrics": serde_json::to_value(martin_report)?,
1542 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1543 });
1544 let json = serde_json::to_string(&combined)?;
1545 println!("{}", json);
1546 }
1547 _ => {
1548 let combined = serde_json::json!({
1549 "martin_metrics": serde_json::to_value(martin_report)?,
1550 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1551 });
1552 let json = serde_json::to_string_pretty(&combined)?;
1553 println!("{}", json);
1554 }
1555 }
1556 Ok(())
1557}
1558
1559fn output_pair_report(report: &CouplingReport, format: OutputFormat) -> Result<()> {
1561 match format {
1562 OutputFormat::Text => {
1563 println!("{}", format_coupling_text(report));
1564 }
1565 OutputFormat::Compact => {
1566 let json = serde_json::to_string(report)?;
1567 println!("{}", json);
1568 }
1569 _ => {
1570 let json = serde_json::to_string_pretty(report)?;
1571 println!("{}", json);
1572 }
1573 }
1574 Ok(())
1575}
1576
1577pub fn format_coupling_project_text(report: &CoreCouplingReport) -> String {
1584 let mut output = String::new();
1585
1586 output.push_str(&format!(
1587 "{}\n\n",
1588 "Coupling Analysis (project-wide)".bold()
1589 ));
1590
1591 if report.top_pairs.is_empty() {
1592 output.push_str(&format!(
1593 "Summary: {} modules, 0 pairs analyzed\n",
1594 report.modules_analyzed,
1595 ));
1596 return output;
1597 }
1598
1599 let all_paths: Vec<&Path> = report
1601 .top_pairs
1602 .iter()
1603 .flat_map(|p| [p.source.as_path(), p.target.as_path()])
1604 .collect();
1605 let prefix = common_path_prefix(&all_paths);
1606
1607 output.push_str(&format!(
1609 " {:>5} {:>5} {:>7} {:>10} {}\n",
1610 "Score", "Calls", "Imports", "Verdict", "Source -> Target"
1611 ));
1612
1613 for pair in &report.top_pairs {
1615 let source_rel = strip_prefix_display(&pair.source, &prefix);
1616 let target_rel = strip_prefix_display(&pair.target, &prefix);
1617
1618 let verdict_str = match pair.verdict {
1619 CoreVerdict::Tight => "tight".red().bold().to_string(),
1620 CoreVerdict::Moderate => "moderate".yellow().to_string(),
1621 CoreVerdict::Loose => "loose".green().to_string(),
1622 };
1623
1624 let score_str = format!("{:.2}", pair.score);
1625 let score_colored = match pair.verdict {
1626 CoreVerdict::Tight => score_str.red().bold().to_string(),
1627 CoreVerdict::Moderate => score_str.yellow().to_string(),
1628 CoreVerdict::Loose => score_str.green().to_string(),
1629 };
1630
1631 output.push_str(&format!(
1632 " {:>5} {:>5} {:>7} {:>10} {} -> {}\n",
1633 score_colored, pair.call_count, pair.import_count, verdict_str, source_rel, target_rel,
1634 ));
1635 }
1636
1637 let avg_str = report
1639 .avg_coupling_score
1640 .map(|s| format!("{:.2}", s))
1641 .unwrap_or_else(|| "N/A".to_string());
1642
1643 output.push_str(&format!(
1644 "\nSummary: {} modules, {} pairs analyzed, {} tight, avg score: {}\n",
1645 report.modules_analyzed, report.pairs_analyzed, report.tight_coupling_count, avg_str,
1646 ));
1647
1648 if report.truncated == Some(true) {
1649 if let Some(total) = report.total_pairs {
1650 output.push_str(&format!(
1651 " (showing top {} of {} pairs)\n",
1652 report.top_pairs.len(),
1653 total,
1654 ));
1655 }
1656 }
1657
1658 output
1659}
1660
1661#[cfg(test)]
1666mod tests {
1667 use super::*;
1668 use std::fs;
1669 use tempfile::TempDir;
1670
1671 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
1673 let path = dir.path().join(name);
1674 fs::write(&path, content).unwrap();
1675 path
1676 }
1677
1678 #[test]
1683 fn test_compute_coupling_score_no_calls() {
1684 let score = compute_coupling_score(0, 0, 5, 5);
1685 assert_eq!(score, 0.0);
1686 }
1687
1688 #[test]
1689 fn test_compute_coupling_score_unidirectional() {
1690 let score = compute_coupling_score(2, 0, 5, 5);
1693 assert!((score - 0.1).abs() < 0.001);
1694 }
1695
1696 #[test]
1697 fn test_compute_coupling_score_bidirectional() {
1698 let score = compute_coupling_score(3, 2, 5, 5);
1701 assert!((score - 0.25).abs() < 0.001);
1702 }
1703
1704 #[test]
1705 fn test_compute_coupling_score_no_functions() {
1706 let score = compute_coupling_score(5, 5, 0, 0);
1707 assert_eq!(score, 0.0);
1708 }
1709
1710 #[test]
1711 fn test_compute_coupling_score_clamped() {
1712 let score = compute_coupling_score(100, 100, 1, 1);
1714 assert_eq!(score, 1.0);
1715 }
1716
1717 #[test]
1722 fn test_verdict_low() {
1723 assert_eq!(CouplingVerdict::from_score(0.0), CouplingVerdict::Low);
1724 assert_eq!(CouplingVerdict::from_score(0.1), CouplingVerdict::Low);
1725 assert_eq!(CouplingVerdict::from_score(0.19), CouplingVerdict::Low);
1726 }
1727
1728 #[test]
1729 fn test_verdict_moderate() {
1730 assert_eq!(CouplingVerdict::from_score(0.2), CouplingVerdict::Moderate);
1731 assert_eq!(CouplingVerdict::from_score(0.3), CouplingVerdict::Moderate);
1732 assert_eq!(CouplingVerdict::from_score(0.39), CouplingVerdict::Moderate);
1733 }
1734
1735 #[test]
1736 fn test_verdict_high() {
1737 assert_eq!(CouplingVerdict::from_score(0.4), CouplingVerdict::High);
1738 assert_eq!(CouplingVerdict::from_score(0.5), CouplingVerdict::High);
1739 assert_eq!(CouplingVerdict::from_score(0.59), CouplingVerdict::High);
1740 }
1741
1742 #[test]
1743 fn test_verdict_very_high() {
1744 assert_eq!(CouplingVerdict::from_score(0.6), CouplingVerdict::VeryHigh);
1745 assert_eq!(CouplingVerdict::from_score(0.8), CouplingVerdict::VeryHigh);
1746 assert_eq!(CouplingVerdict::from_score(1.0), CouplingVerdict::VeryHigh);
1747 }
1748
1749 #[test]
1754 fn test_extract_defined_names() {
1755 let source = r#"
1756def func_a():
1757 pass
1758
1759async def func_b():
1760 pass
1761
1762class MyClass:
1763 pass
1764"#;
1765 let temp = TempDir::new().unwrap();
1766 let path = create_test_file(&temp, "test.py", source);
1767 let info = extract_module_info(&path, source).unwrap();
1768
1769 assert!(info.defined_names.contains("func_a"));
1770 assert!(info.defined_names.contains("func_b"));
1771 assert!(info.defined_names.contains("MyClass"));
1772 assert_eq!(info.function_count, 2);
1773 }
1774
1775 #[test]
1776 fn test_extract_imports() {
1777 let source = r#"
1778import os
1779import sys as system
1780from pathlib import Path
1781from collections import defaultdict, Counter
1782from typing import List as L
1783"#;
1784 let temp = TempDir::new().unwrap();
1785 let path = create_test_file(&temp, "test.py", source);
1786 let info = extract_module_info(&path, source).unwrap();
1787
1788 assert!(info.imports.contains_key("os"));
1789 assert!(info.imports.contains_key("system"));
1790 assert!(info.imports.contains_key("Path"));
1791 assert!(info.imports.contains_key("defaultdict"));
1792 assert!(info.imports.contains_key("Counter"));
1793 }
1794
1795 #[test]
1796 fn test_extract_calls() {
1797 let source = r#"
1798def caller():
1799 result = helper()
1800 obj.method()
1801 other_func(1, 2, 3)
1802 return result
1803"#;
1804 let temp = TempDir::new().unwrap();
1805 let path = create_test_file(&temp, "test.py", source);
1806 let info = extract_module_info(&path, source).unwrap();
1807
1808 let callees: Vec<&str> = info
1810 .calls
1811 .iter()
1812 .map(|(_, callee, _)| callee.as_str())
1813 .collect();
1814 assert!(callees.contains(&"helper"));
1815 assert!(callees.contains(&"method"));
1816 assert!(callees.contains(&"other_func"));
1817 }
1818
1819 #[test]
1824 fn test_find_cross_calls_simple() {
1825 let temp = TempDir::new().unwrap();
1826
1827 let source_a = r#"
1829from module_b import helper
1830
1831def caller():
1832 return helper()
1833"#;
1834 let path_a = create_test_file(&temp, "module_a.py", source_a);
1835 let info_a = extract_module_info(&path_a, source_a).unwrap();
1836
1837 let source_b = r#"
1839def helper():
1840 return 42
1841"#;
1842 let path_b = create_test_file(&temp, "module_b.py", source_b);
1843 let info_b = extract_module_info(&path_b, source_b).unwrap();
1844
1845 let cross_calls = find_cross_calls(&info_a, &info_b);
1846
1847 assert_eq!(cross_calls.count, 1);
1848 assert_eq!(cross_calls.calls[0].caller, "caller");
1849 assert_eq!(cross_calls.calls[0].callee, "helper");
1850 }
1851
1852 #[test]
1853 fn test_find_cross_calls_no_import() {
1854 let temp = TempDir::new().unwrap();
1855
1856 let source_a = r#"
1858def caller():
1859 return helper()
1860"#;
1861 let path_a = create_test_file(&temp, "module_a.py", source_a);
1862 let info_a = extract_module_info(&path_a, source_a).unwrap();
1863
1864 let source_b = r#"
1866def helper():
1867 return 42
1868"#;
1869 let path_b = create_test_file(&temp, "module_b.py", source_b);
1870 let info_b = extract_module_info(&path_b, source_b).unwrap();
1871
1872 let cross_calls = find_cross_calls(&info_a, &info_b);
1873
1874 assert_eq!(cross_calls.count, 0);
1876 }
1877
1878 #[test]
1879 fn test_find_cross_calls_bidirectional() {
1880 let temp = TempDir::new().unwrap();
1881
1882 let source_a = r#"
1884from module_b import helper_b
1885
1886def func_a():
1887 return helper_b()
1888"#;
1889 let path_a = create_test_file(&temp, "module_a.py", source_a);
1890 let info_a = extract_module_info(&path_a, source_a).unwrap();
1891
1892 let source_b = r#"
1894from module_a import func_a
1895
1896def helper_b():
1897 return 42
1898
1899def caller_b():
1900 return func_a()
1901"#;
1902 let path_b = create_test_file(&temp, "module_b.py", source_b);
1903 let info_b = extract_module_info(&path_b, source_b).unwrap();
1904
1905 let a_to_b = find_cross_calls(&info_a, &info_b);
1906 let b_to_a = find_cross_calls(&info_b, &info_a);
1907
1908 assert_eq!(a_to_b.count, 1);
1909 assert_eq!(b_to_a.count, 1);
1910 }
1911
1912 #[test]
1917 fn test_format_coupling_text() {
1918 let report = CouplingReport {
1919 path_a: "src/auth.py".to_string(),
1920 path_b: "src/user.py".to_string(),
1921 a_to_b: CrossCalls {
1922 calls: vec![CrossCall {
1923 caller: "login".to_string(),
1924 callee: "get_user".to_string(),
1925 line: 10,
1926 }],
1927 count: 1,
1928 },
1929 b_to_a: CrossCalls::default(),
1930 total_calls: 1,
1931 coupling_score: 0.15,
1932 verdict: CouplingVerdict::Low,
1933 };
1934
1935 let text = format_coupling_text(&report);
1936
1937 assert!(text.contains("src/auth.py"));
1938 assert!(text.contains("src/user.py"));
1939 assert!(text.contains("0.15"));
1940 assert!(text.contains("low"));
1941 assert!(text.contains("login"));
1942 assert!(text.contains("get_user"));
1943 assert!(text.contains("line 10"));
1944 }
1945
1946 #[test]
1951 fn test_run_no_coupling() {
1952 let temp = TempDir::new().unwrap();
1953
1954 let source_a = r#"
1955def standalone_a():
1956 return 1
1957"#;
1958 let source_b = r#"
1959def standalone_b():
1960 return 2
1961"#;
1962
1963 let path_a = create_test_file(&temp, "a.py", source_a);
1964 let path_b = create_test_file(&temp, "b.py", source_b);
1965
1966 let args = CouplingArgs {
1967 path_a: path_a.clone(),
1968 path_b: Some(path_b.clone()),
1969 timeout: 30,
1970 project_root: None,
1971 max_pairs: 20,
1972 top: 0,
1973 cycles_only: false,
1974 lang: None,
1975 include_tests: false,
1976 };
1977
1978 let result = run(args, OutputFormat::Json);
1980 assert!(result.is_ok());
1981 }
1982
1983 #[test]
1984 fn test_run_with_coupling() {
1985 let temp = TempDir::new().unwrap();
1986
1987 let source_a = r#"
1988from b import helper
1989
1990def caller():
1991 return helper()
1992"#;
1993 let source_b = r#"
1994def helper():
1995 return 42
1996"#;
1997
1998 let path_a = create_test_file(&temp, "a.py", source_a);
1999 let path_b = create_test_file(&temp, "b.py", source_b);
2000
2001 let args = CouplingArgs {
2002 path_a: path_a.clone(),
2003 path_b: Some(path_b.clone()),
2004 timeout: 30,
2005 project_root: None,
2006 max_pairs: 20,
2007 top: 0,
2008 cycles_only: false,
2009 lang: None,
2010 include_tests: false,
2011 };
2012
2013 let result = run(args, OutputFormat::Json);
2014 assert!(result.is_ok());
2015 }
2016
2017 #[test]
2022 fn test_go_extract_module_info() {
2023 let source = r#"
2024package main
2025
2026import (
2027 "fmt"
2028 "myapp/utils"
2029)
2030
2031func Caller() {
2032 utils.Helper()
2033 fmt.Println("hello")
2034}
2035
2036func Standalone() int {
2037 return 42
2038}
2039"#;
2040 let temp = TempDir::new().unwrap();
2041 let path = create_test_file(&temp, "main.go", source);
2042 let info = extract_module_info(&path, source).unwrap();
2043
2044 assert!(info.defined_names.contains("Caller"), "missing Caller");
2046 assert!(
2047 info.defined_names.contains("Standalone"),
2048 "missing Standalone"
2049 );
2050 assert_eq!(info.function_count, 2);
2051
2052 assert!(
2054 info.imports.contains_key("fmt") || info.imports.values().any(|v| v.contains("fmt")),
2055 "missing fmt import: {:?}",
2056 info.imports
2057 );
2058 }
2059
2060 #[test]
2061 fn test_go_cross_calls() {
2062 let temp = TempDir::new().unwrap();
2063
2064 let source_a = r#"
2065package main
2066
2067import "myapp/pkg_b"
2068
2069func CallerA() {
2070 pkg_b.HelperB()
2071}
2072"#;
2073 let source_b = r#"
2074package pkg_b
2075
2076func HelperB() int {
2077 return 42
2078}
2079"#;
2080 let path_a = create_test_file(&temp, "a.go", source_a);
2081 let path_b = create_test_file(&temp, "b.go", source_b);
2082
2083 let info_a = extract_module_info(&path_a, source_a).unwrap();
2084 let info_b = extract_module_info(&path_b, source_b).unwrap();
2085
2086 let a_to_b = find_cross_calls(&info_a, &info_b);
2088 assert!(
2089 a_to_b.count >= 1,
2090 "expected cross-calls from A to B, got {}",
2091 a_to_b.count
2092 );
2093 }
2094
2095 #[test]
2096 fn test_rust_extract_module_info() {
2097 let source = r#"
2098use std::collections::HashMap;
2099use crate::module_b::helper;
2100
2101pub fn caller() {
2102 let _ = helper();
2103}
2104
2105fn standalone() -> i32 {
2106 42
2107}
2108"#;
2109 let temp = TempDir::new().unwrap();
2110 let path = create_test_file(&temp, "lib.rs", source);
2111 let info = extract_module_info(&path, source).unwrap();
2112
2113 assert!(info.defined_names.contains("caller"), "missing caller");
2115 assert!(
2116 info.defined_names.contains("standalone"),
2117 "missing standalone"
2118 );
2119 assert_eq!(info.function_count, 2);
2120
2121 assert!(
2123 !info.imports.is_empty(),
2124 "should have imports: {:?}",
2125 info.imports
2126 );
2127 }
2128
2129 #[test]
2130 fn test_typescript_extract_module_info() {
2131 let source = r#"
2132import { helper } from './module_b';
2133import * as utils from './utils';
2134
2135function caller(): void {
2136 helper();
2137 utils.doStuff();
2138}
2139
2140function standalone(): number {
2141 return 42;
2142}
2143"#;
2144 let temp = TempDir::new().unwrap();
2145 let path = create_test_file(&temp, "main.ts", source);
2146 let info = extract_module_info(&path, source).unwrap();
2147
2148 assert!(info.defined_names.contains("caller"), "missing caller");
2150 assert!(
2151 info.defined_names.contains("standalone"),
2152 "missing standalone"
2153 );
2154 assert_eq!(info.function_count, 2);
2155
2156 assert!(
2158 !info.imports.is_empty(),
2159 "should have imports: {:?}",
2160 info.imports
2161 );
2162 }
2163
2164 #[test]
2165 fn test_java_extract_module_info() {
2166 let source = r#"
2167import com.example.utils.Helper;
2168import java.util.List;
2169
2170public class Main {
2171 public void caller() {
2172 Helper.doWork();
2173 }
2174
2175 public int standalone() {
2176 return 42;
2177 }
2178}
2179"#;
2180 let temp = TempDir::new().unwrap();
2181 let path = create_test_file(&temp, "Main.java", source);
2182 let info = extract_module_info(&path, source).unwrap();
2183
2184 assert!(info.defined_names.contains("caller"), "missing caller");
2186 assert!(
2187 info.defined_names.contains("standalone"),
2188 "missing standalone"
2189 );
2190
2191 assert!(
2193 !info.imports.is_empty(),
2194 "should have imports: {:?}",
2195 info.imports
2196 );
2197 }
2198
2199 #[test]
2200 fn test_c_extract_module_info() {
2201 let source = r#"
2202#include <stdio.h>
2203#include "mylib.h"
2204
2205void caller() {
2206 helper();
2207 printf("hello\n");
2208}
2209
2210int standalone() {
2211 return 42;
2212}
2213"#;
2214 let temp = TempDir::new().unwrap();
2215 let path = create_test_file(&temp, "main.c", source);
2216 let info = extract_module_info(&path, source).unwrap();
2217
2218 assert!(info.defined_names.contains("caller"), "missing caller");
2220 assert!(
2221 info.defined_names.contains("standalone"),
2222 "missing standalone"
2223 );
2224 assert_eq!(info.function_count, 2);
2225
2226 assert!(
2228 !info.imports.is_empty(),
2229 "should have imports from #include: {:?}",
2230 info.imports
2231 );
2232 }
2233
2234 #[test]
2235 fn test_ruby_extract_module_info() {
2236 let source = r#"
2237require 'json'
2238require_relative 'helper'
2239
2240def caller
2241 helper_method
2242 JSON.parse("{}")
2243end
2244
2245def standalone
2246 42
2247end
2248"#;
2249 let temp = TempDir::new().unwrap();
2250 let path = create_test_file(&temp, "main.rb", source);
2251 let info = extract_module_info(&path, source).unwrap();
2252
2253 assert!(info.defined_names.contains("caller"), "missing caller");
2255 assert!(
2256 info.defined_names.contains("standalone"),
2257 "missing standalone"
2258 );
2259 assert_eq!(info.function_count, 2);
2260
2261 assert!(
2263 !info.imports.is_empty(),
2264 "should have imports from require: {:?}",
2265 info.imports
2266 );
2267 }
2268
2269 #[test]
2270 fn test_cpp_extract_module_info() {
2271 let source = r#"
2272#include <iostream>
2273#include "mylib.hpp"
2274
2275void caller() {
2276 helper();
2277 std::cout << "hello" << std::endl;
2278}
2279
2280int standalone() {
2281 return 42;
2282}
2283"#;
2284 let temp = TempDir::new().unwrap();
2285 let path = create_test_file(&temp, "main.cpp", source);
2286 let info = extract_module_info(&path, source).unwrap();
2287
2288 assert!(info.defined_names.contains("caller"), "missing caller");
2289 assert!(
2290 info.defined_names.contains("standalone"),
2291 "missing standalone"
2292 );
2293 assert_eq!(info.function_count, 2);
2294 assert!(
2295 !info.imports.is_empty(),
2296 "should have imports from #include: {:?}",
2297 info.imports
2298 );
2299 }
2300
2301 #[test]
2302 fn test_php_extract_module_info() {
2303 let source = r#"<?php
2304use App\Utils\Helper;
2305use Symfony\Component\Console\Command;
2306
2307function caller() {
2308 Helper::doWork();
2309}
2310
2311function standalone() {
2312 return 42;
2313}
2314"#;
2315 let temp = TempDir::new().unwrap();
2316 let path = create_test_file(&temp, "main.php", source);
2317 let info = extract_module_info(&path, source).unwrap();
2318
2319 assert!(info.defined_names.contains("caller"), "missing caller");
2320 assert!(
2321 info.defined_names.contains("standalone"),
2322 "missing standalone"
2323 );
2324 assert_eq!(info.function_count, 2);
2325 assert!(
2326 !info.imports.is_empty(),
2327 "should have imports from use: {:?}",
2328 info.imports
2329 );
2330 }
2331
2332 #[test]
2333 fn test_csharp_extract_module_info() {
2334 let source = r#"
2335using System;
2336using MyApp.Utils;
2337
2338public class Main {
2339 public void Caller() {
2340 Helper.DoWork();
2341 }
2342
2343 public int Standalone() {
2344 return 42;
2345 }
2346}
2347"#;
2348 let temp = TempDir::new().unwrap();
2349 let path = create_test_file(&temp, "Main.cs", source);
2350 let info = extract_module_info(&path, source).unwrap();
2351
2352 assert!(info.defined_names.contains("Caller"), "missing Caller");
2353 assert!(
2354 info.defined_names.contains("Standalone"),
2355 "missing Standalone"
2356 );
2357 assert!(
2358 !info.imports.is_empty(),
2359 "should have imports from using: {:?}",
2360 info.imports
2361 );
2362 }
2363
2364 #[test]
2365 fn test_run_go_coupling() {
2366 let temp = TempDir::new().unwrap();
2367
2368 let source_a = r#"
2369package main
2370
2371func standalone_a() int {
2372 return 1
2373}
2374"#;
2375 let source_b = r#"
2376package main
2377
2378func standalone_b() int {
2379 return 2
2380}
2381"#;
2382
2383 let path_a = create_test_file(&temp, "a.go", source_a);
2384 let path_b = create_test_file(&temp, "b.go", source_b);
2385
2386 let args = CouplingArgs {
2387 path_a: path_a.clone(),
2388 path_b: Some(path_b.clone()),
2389 timeout: 30,
2390 project_root: None,
2391 max_pairs: 20,
2392 top: 0,
2393 cycles_only: false,
2394 lang: None,
2395 include_tests: false,
2396 };
2397
2398 let result = run(args, OutputFormat::Json);
2399 assert!(
2400 result.is_ok(),
2401 "coupling should work for Go files: {:?}",
2402 result.err()
2403 );
2404 }
2405
2406 #[test]
2407 fn test_run_rust_coupling() {
2408 let temp = TempDir::new().unwrap();
2409
2410 let source_a = r#"
2411fn standalone_a() -> i32 {
2412 1
2413}
2414"#;
2415 let source_b = r#"
2416fn standalone_b() -> i32 {
2417 2
2418}
2419"#;
2420
2421 let path_a = create_test_file(&temp, "a.rs", source_a);
2422 let path_b = create_test_file(&temp, "b.rs", source_b);
2423
2424 let args = CouplingArgs {
2425 path_a: path_a.clone(),
2426 path_b: Some(path_b.clone()),
2427 timeout: 30,
2428 project_root: None,
2429 max_pairs: 20,
2430 top: 0,
2431 cycles_only: false,
2432 lang: None,
2433 include_tests: false,
2434 };
2435
2436 let result = run(args, OutputFormat::Json);
2437 assert!(
2438 result.is_ok(),
2439 "coupling should work for Rust files: {:?}",
2440 result.err()
2441 );
2442 }
2443
2444 #[test]
2445 fn test_unsupported_extension_returns_error() {
2446 let temp = TempDir::new().unwrap();
2447 let path = create_test_file(&temp, "data.xyz", "some content");
2448 let result = extract_module_info(&path, "some content");
2449 assert!(
2450 result.is_err(),
2451 "unsupported file extension should return error"
2452 );
2453 }
2454
2455 #[test]
2460 fn test_coupling_args_pair_mode_backward_compat() {
2461 let args = CouplingArgs {
2463 path_a: PathBuf::from("src/a.py"),
2464 path_b: Some(PathBuf::from("src/b.py")),
2465 timeout: 30,
2466 project_root: None,
2467 max_pairs: 20,
2468 top: 0,
2469 cycles_only: false,
2470 lang: None,
2471 include_tests: false,
2472 };
2473 assert!(args.path_b.is_some());
2474 }
2475
2476 #[test]
2477 fn test_coupling_args_project_wide_mode() {
2478 let args = CouplingArgs {
2480 path_a: PathBuf::from("src/"),
2481 path_b: None,
2482 timeout: 30,
2483 project_root: None,
2484 max_pairs: 20,
2485 top: 0,
2486 cycles_only: false,
2487 lang: None,
2488 include_tests: false,
2489 };
2490 assert!(args.path_b.is_none());
2491 }
2492
2493 #[test]
2494 fn test_coupling_args_max_pairs_default() {
2495 let args = CouplingArgs {
2496 path_a: PathBuf::from("src/"),
2497 path_b: None,
2498 timeout: 30,
2499 project_root: None,
2500 max_pairs: 20,
2501 top: 0,
2502 cycles_only: false,
2503 lang: None,
2504 include_tests: false,
2505 };
2506 assert_eq!(args.max_pairs, 20);
2507 }
2508
2509 #[test]
2510 fn test_coupling_args_max_pairs_custom() {
2511 let args = CouplingArgs {
2512 path_a: PathBuf::from("src/"),
2513 path_b: None,
2514 timeout: 30,
2515 project_root: None,
2516 max_pairs: 5,
2517 top: 0,
2518 cycles_only: false,
2519 lang: None,
2520 include_tests: false,
2521 };
2522 assert_eq!(args.max_pairs, 5);
2523 }
2524
2525 #[test]
2526 fn test_run_project_wide_mode() {
2527 let temp = TempDir::new().unwrap();
2528
2529 let source_a = r#"
2531from b import helper
2532
2533def caller():
2534 return helper()
2535"#;
2536 let source_b = r#"
2537def helper():
2538 return 42
2539"#;
2540 let source_c = r#"
2541def standalone():
2542 return 99
2543"#;
2544
2545 create_test_file(&temp, "a.py", source_a);
2546 create_test_file(&temp, "b.py", source_b);
2547 create_test_file(&temp, "c.py", source_c);
2548
2549 let args = CouplingArgs {
2551 path_a: temp.path().to_path_buf(),
2552 path_b: None,
2553 timeout: 30,
2554 project_root: None,
2555 max_pairs: 20,
2556 top: 0,
2557 cycles_only: false,
2558 lang: None,
2559 include_tests: false,
2560 };
2561
2562 let result = run(args, OutputFormat::Json);
2563 assert!(
2564 result.is_ok(),
2565 "project-wide coupling should succeed: {:?}",
2566 result.err()
2567 );
2568 }
2569
2570 #[test]
2571 fn test_run_pair_mode_still_works() {
2572 let temp = TempDir::new().unwrap();
2574
2575 let source_a = r#"
2576from b import helper
2577
2578def caller():
2579 return helper()
2580"#;
2581 let source_b = r#"
2582def helper():
2583 return 42
2584"#;
2585
2586 let path_a = create_test_file(&temp, "a.py", source_a);
2587 let path_b = create_test_file(&temp, "b.py", source_b);
2588
2589 let args = CouplingArgs {
2590 path_a: path_a.clone(),
2591 path_b: Some(path_b.clone()),
2592 timeout: 30,
2593 project_root: None,
2594 max_pairs: 20,
2595 top: 0,
2596 cycles_only: false,
2597 lang: None,
2598 include_tests: false,
2599 };
2600
2601 let result = run(args, OutputFormat::Json);
2602 assert!(
2603 result.is_ok(),
2604 "pair mode should still work: {:?}",
2605 result.err()
2606 );
2607 }
2608
2609 #[test]
2610 fn test_format_coupling_project_text_basic() {
2611 use tldr_core::quality::coupling::{
2612 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2613 ModuleCoupling as CoreModuleCoupling,
2614 };
2615
2616 let report = CoreCouplingReport {
2617 modules_analyzed: 10,
2618 pairs_analyzed: 45,
2619 total_cross_file_pairs: 8,
2620 avg_coupling_score: Some(0.25),
2621 tight_coupling_count: 2,
2622 top_pairs: vec![
2623 CoreModuleCoupling {
2624 source: PathBuf::from("src/services/auth.rs"),
2625 target: PathBuf::from("src/db/users.rs"),
2626 import_count: 8,
2627 call_count: 12,
2628 calls_source_to_target: vec![],
2629 calls_target_to_source: vec![],
2630 shared_imports: vec![],
2631 score: 0.72,
2632 verdict: CoreVerdict::Tight,
2633 },
2634 CoreModuleCoupling {
2635 source: PathBuf::from("src/api/routes.rs"),
2636 target: PathBuf::from("src/services/auth.rs"),
2637 import_count: 5,
2638 call_count: 7,
2639 calls_source_to_target: vec![],
2640 calls_target_to_source: vec![],
2641 shared_imports: vec![],
2642 score: 0.55,
2643 verdict: CoreVerdict::Moderate,
2644 },
2645 CoreModuleCoupling {
2646 source: PathBuf::from("src/handlers/web.rs"),
2647 target: PathBuf::from("src/api/routes.rs"),
2648 import_count: 3,
2649 call_count: 5,
2650 calls_source_to_target: vec![],
2651 calls_target_to_source: vec![],
2652 shared_imports: vec![],
2653 score: 0.15,
2654 verdict: CoreVerdict::Loose,
2655 },
2656 ],
2657 truncated: None,
2658 total_pairs: None,
2659 shown_pairs: None,
2660 };
2661
2662 let text = format_coupling_project_text(&report);
2663
2664 assert!(
2666 text.contains("project-wide"),
2667 "should contain 'project-wide': {}",
2668 text
2669 );
2670 assert!(
2672 text.contains("Score"),
2673 "should contain Score header: {}",
2674 text
2675 );
2676 assert!(
2677 text.contains("Calls"),
2678 "should contain Calls header: {}",
2679 text
2680 );
2681 assert!(
2682 text.contains("Imports"),
2683 "should contain Imports header: {}",
2684 text
2685 );
2686 assert!(
2687 text.contains("Verdict"),
2688 "should contain Verdict header: {}",
2689 text
2690 );
2691 assert!(
2693 text.contains("0.72"),
2694 "should contain tight score: {}",
2695 text
2696 );
2697 assert!(
2698 text.contains("0.55"),
2699 "should contain moderate score: {}",
2700 text
2701 );
2702 assert!(
2703 text.contains("0.15"),
2704 "should contain loose score: {}",
2705 text
2706 );
2707 assert!(
2709 text.contains("tight"),
2710 "should contain tight verdict: {}",
2711 text
2712 );
2713 assert!(
2714 text.contains("moderate"),
2715 "should contain moderate verdict: {}",
2716 text
2717 );
2718 assert!(
2719 text.contains("loose"),
2720 "should contain loose verdict: {}",
2721 text
2722 );
2723 assert!(
2725 text.contains("10 modules"),
2726 "should contain module count: {}",
2727 text
2728 );
2729 assert!(
2730 text.contains("45 pairs"),
2731 "should contain pair count: {}",
2732 text
2733 );
2734 assert!(
2735 text.contains("2 tight"),
2736 "should contain tight count: {}",
2737 text
2738 );
2739 }
2740
2741 #[test]
2742 fn test_format_coupling_project_text_empty() {
2743 use tldr_core::quality::coupling::CouplingReport as CoreCouplingReport;
2744
2745 let report = CoreCouplingReport::default();
2746
2747 let text = format_coupling_project_text(&report);
2748
2749 assert!(
2750 text.contains("project-wide"),
2751 "should contain 'project-wide': {}",
2752 text
2753 );
2754 assert!(
2755 text.contains("0 modules"),
2756 "should contain zero modules: {}",
2757 text
2758 );
2759 }
2760
2761 #[test]
2766 fn test_format_martin_text_basic() {
2767 use tldr_core::quality::coupling::{
2768 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2769 };
2770
2771 let report = MartinMetricsReport {
2772 schema_version: "1.0".to_string(),
2773 modules_analyzed: 2,
2774 metrics: vec![
2775 MartinModuleMetrics {
2776 module: PathBuf::from("src/api.py"),
2777 ca: 0,
2778 ce: 3,
2779 instability: 1.0,
2780 in_cycle: false,
2781 },
2782 MartinModuleMetrics {
2783 module: PathBuf::from("src/db.py"),
2784 ca: 2,
2785 ce: 0,
2786 instability: 0.0,
2787 in_cycle: false,
2788 },
2789 ],
2790 cycles: vec![],
2791 summary: MartinSummary {
2792 avg_instability: 0.5,
2793 total_cycles: 0,
2794 most_stable: Some(PathBuf::from("src/db.py")),
2795 most_unstable: Some(PathBuf::from("src/api.py")),
2796 },
2797 };
2798
2799 let text = format_martin_text(&report);
2800 assert!(
2801 text.contains("Module"),
2802 "should contain Module header: {}",
2803 text
2804 );
2805 assert!(text.contains("Ca"), "should contain Ca header: {}", text);
2806 assert!(text.contains("Ce"), "should contain Ce header: {}", text);
2807 assert!(
2808 text.contains("Cycle?"),
2809 "should contain Cycle? header: {}",
2810 text
2811 );
2812 }
2813
2814 #[test]
2815 fn test_format_martin_text_empty() {
2816 use tldr_core::quality::coupling::MartinMetricsReport;
2817
2818 let report = MartinMetricsReport::default();
2819 let text = format_martin_text(&report);
2820 assert!(
2821 text.contains("No modules found"),
2822 "empty report should say 'No modules found': {}",
2823 text
2824 );
2825 }
2826
2827 #[test]
2828 fn test_format_martin_text_with_cycles() {
2829 use tldr_core::analysis::deps::DepCycle;
2830 use tldr_core::quality::coupling::{
2831 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2832 };
2833
2834 let cycle = DepCycle::new(vec![PathBuf::from("a.py"), PathBuf::from("b.py")]);
2835 let report = MartinMetricsReport {
2836 schema_version: "1.0".to_string(),
2837 modules_analyzed: 2,
2838 metrics: vec![
2839 MartinModuleMetrics {
2840 module: PathBuf::from("a.py"),
2841 ca: 1,
2842 ce: 1,
2843 instability: 0.5,
2844 in_cycle: true,
2845 },
2846 MartinModuleMetrics {
2847 module: PathBuf::from("b.py"),
2848 ca: 1,
2849 ce: 1,
2850 instability: 0.5,
2851 in_cycle: true,
2852 },
2853 ],
2854 cycles: vec![cycle],
2855 summary: MartinSummary {
2856 avg_instability: 0.5,
2857 total_cycles: 1,
2858 most_stable: Some(PathBuf::from("a.py")),
2859 most_unstable: Some(PathBuf::from("a.py")),
2860 },
2861 };
2862
2863 let text = format_martin_text(&report);
2864 assert!(
2865 text.contains("Cycles:"),
2866 "should contain 'Cycles:' section: {}",
2867 text
2868 );
2869 assert!(
2870 text.contains("->"),
2871 "should contain '->' in cycle display: {}",
2872 text
2873 );
2874 }
2875
2876 #[test]
2877 fn test_format_martin_text_no_cycles() {
2878 use tldr_core::quality::coupling::{
2879 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2880 };
2881
2882 let report = MartinMetricsReport {
2883 schema_version: "1.0".to_string(),
2884 modules_analyzed: 1,
2885 metrics: vec![MartinModuleMetrics {
2886 module: PathBuf::from("a.py"),
2887 ca: 0,
2888 ce: 0,
2889 instability: 0.0,
2890 in_cycle: false,
2891 }],
2892 cycles: vec![],
2893 summary: MartinSummary {
2894 avg_instability: 0.0,
2895 total_cycles: 0,
2896 most_stable: Some(PathBuf::from("a.py")),
2897 most_unstable: Some(PathBuf::from("a.py")),
2898 },
2899 };
2900
2901 let text = format_martin_text(&report);
2902 assert!(
2903 !text.contains("Cycles:"),
2904 "should NOT contain 'Cycles:' section when no cycles: {}",
2905 text
2906 );
2907 }
2908
2909 #[test]
2910 fn test_format_martin_text_summary_line() {
2911 use tldr_core::quality::coupling::{
2912 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2913 };
2914
2915 let report = MartinMetricsReport {
2916 schema_version: "1.0".to_string(),
2917 modules_analyzed: 3,
2918 metrics: vec![MartinModuleMetrics {
2919 module: PathBuf::from("a.py"),
2920 ca: 0,
2921 ce: 1,
2922 instability: 1.0,
2923 in_cycle: false,
2924 }],
2925 cycles: vec![],
2926 summary: MartinSummary {
2927 avg_instability: 0.5,
2928 total_cycles: 0,
2929 most_stable: Some(PathBuf::from("c.py")),
2930 most_unstable: Some(PathBuf::from("a.py")),
2931 },
2932 };
2933
2934 let text = format_martin_text(&report);
2935 assert!(
2936 text.contains("modules"),
2937 "should contain 'modules' in summary: {}",
2938 text
2939 );
2940 assert!(
2941 text.contains("avg instability"),
2942 "should contain 'avg instability' in summary: {}",
2943 text
2944 );
2945 }
2946
2947 #[test]
2948 fn test_format_coupling_project_text_path_stripping() {
2949 use tldr_core::quality::coupling::{
2950 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2951 ModuleCoupling as CoreModuleCoupling,
2952 };
2953
2954 let report = CoreCouplingReport {
2955 modules_analyzed: 2,
2956 pairs_analyzed: 1,
2957 total_cross_file_pairs: 1,
2958 avg_coupling_score: Some(0.50),
2959 tight_coupling_count: 0,
2960 top_pairs: vec![CoreModuleCoupling {
2961 source: PathBuf::from("/home/user/project/src/auth.rs"),
2962 target: PathBuf::from("/home/user/project/src/db.rs"),
2963 import_count: 3,
2964 call_count: 4,
2965 calls_source_to_target: vec![],
2966 calls_target_to_source: vec![],
2967 shared_imports: vec![],
2968 score: 0.50,
2969 verdict: CoreVerdict::Moderate,
2970 }],
2971 truncated: None,
2972 total_pairs: None,
2973 shown_pairs: None,
2974 };
2975
2976 let text = format_coupling_project_text(&report);
2977
2978 assert!(
2980 text.contains("auth.rs"),
2981 "should show relative path auth.rs: {}",
2982 text
2983 );
2984 assert!(
2985 text.contains("db.rs"),
2986 "should show relative path db.rs: {}",
2987 text
2988 );
2989 assert!(
2991 !text.contains("/home/user/project/src/auth.rs"),
2992 "should strip common prefix from paths: {}",
2993 text
2994 );
2995 }
2996
2997 #[test]
3002 fn test_coupling_args_top_flag() {
3003 let args = CouplingArgs {
3005 path_a: PathBuf::from("src/"),
3006 path_b: None,
3007 timeout: 30,
3008 project_root: None,
3009 max_pairs: 20,
3010 top: 5,
3011 cycles_only: false,
3012 lang: None,
3013 include_tests: false,
3014 };
3015 assert_eq!(args.top, 5);
3016 }
3017
3018 #[test]
3019 fn test_coupling_args_cycles_only_flag() {
3020 let args = CouplingArgs {
3022 path_a: PathBuf::from("src/"),
3023 path_b: None,
3024 timeout: 30,
3025 project_root: None,
3026 max_pairs: 20,
3027 top: 0,
3028 cycles_only: true,
3029 lang: None,
3030 include_tests: false,
3031 };
3032 assert!(args.cycles_only);
3033 }
3034
3035 #[test]
3036 fn test_coupling_args_defaults() {
3037 let args = CouplingArgs {
3039 path_a: PathBuf::from("src/"),
3040 path_b: None,
3041 timeout: 30,
3042 project_root: None,
3043 max_pairs: 20,
3044 top: 0,
3045 cycles_only: false,
3046 lang: None,
3047 include_tests: false,
3048 };
3049 assert_eq!(args.top, 0);
3050 assert!(!args.cycles_only);
3051 }
3052
3053 #[test]
3054 fn test_project_mode_produces_martin_output() {
3055 let temp = TempDir::new().unwrap();
3057
3058 create_test_file(
3059 &temp,
3060 "a.py",
3061 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3062 );
3063 create_test_file(
3064 &temp,
3065 "b.py",
3066 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3067 );
3068 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3069
3070 let args = CouplingArgs {
3071 path_a: temp.path().to_path_buf(),
3072 path_b: None,
3073 timeout: 30,
3074 project_root: None,
3075 max_pairs: 20,
3076 top: 0,
3077 cycles_only: false,
3078 lang: None,
3079 include_tests: false,
3080 };
3081
3082 let result = run(args, OutputFormat::Text);
3084 assert!(
3085 result.is_ok(),
3086 "project mode should succeed: {:?}",
3087 result.err()
3088 );
3089 }
3092
3093 #[test]
3094 fn test_project_mode_json_has_martin_fields() {
3095 use serde_json::Value;
3096
3097 let temp = TempDir::new().unwrap();
3098
3099 create_test_file(
3100 &temp,
3101 "a.py",
3102 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3103 );
3104 create_test_file(
3105 &temp,
3106 "b.py",
3107 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3108 );
3109 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3110
3111 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3113 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3114
3115 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3116 let martin_report = compute_martin_metrics_from_deps(
3117 &deps_report,
3118 &MartinOptions {
3119 top: 0,
3120 cycles_only: false,
3121 },
3122 );
3123
3124 let json = serde_json::to_string_pretty(&martin_report).unwrap();
3125 let parsed: Value = serde_json::from_str(&json).unwrap();
3126
3127 assert!(
3128 parsed.get("modules_analyzed").is_some(),
3129 "JSON should have 'modules_analyzed': {}",
3130 json
3131 );
3132 assert!(
3133 parsed.get("metrics").is_some(),
3134 "JSON should have 'metrics': {}",
3135 json
3136 );
3137 assert!(
3138 parsed.get("summary").is_some(),
3139 "JSON should have 'summary': {}",
3140 json
3141 );
3142 }
3143
3144 #[test]
3145 fn test_project_mode_cycles_only_filter() {
3146 let temp = TempDir::new().unwrap();
3148
3149 create_test_file(
3150 &temp,
3151 "a.py",
3152 "from b import func_b\n\ndef func_a():\n return func_b()\n",
3153 );
3154 create_test_file(
3155 &temp,
3156 "b.py",
3157 "from a import func_a\n\ndef func_b():\n return func_a()\n",
3158 );
3159 create_test_file(
3160 &temp,
3161 "c.py",
3162 "from d import func_d\n\ndef func_c():\n return func_d()\n",
3163 );
3164 create_test_file(&temp, "d.py", "def func_d():\n return 42\n");
3165
3166 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3167 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3168
3169 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3170 let martin_report = compute_martin_metrics_from_deps(
3171 &deps_report,
3172 &MartinOptions {
3173 top: 0,
3174 cycles_only: true,
3175 },
3176 );
3177
3178 for m in &martin_report.metrics {
3180 assert!(
3181 m.in_cycle,
3182 "cycles_only filter should only include cycle modules, got: {:?}",
3183 m.module
3184 );
3185 }
3186 }
3187
3188 #[test]
3189 fn test_project_mode_top_n_limits() {
3190 let temp = TempDir::new().unwrap();
3192
3193 create_test_file(
3194 &temp,
3195 "a.py",
3196 "from b import fb\n\ndef fa():\n return fb()\n",
3197 );
3198 create_test_file(
3199 &temp,
3200 "b.py",
3201 "from c import fc\n\ndef fb():\n return fc()\n",
3202 );
3203 create_test_file(
3204 &temp,
3205 "c.py",
3206 "from d import fd\n\ndef fc():\n return fd()\n",
3207 );
3208 create_test_file(
3209 &temp,
3210 "d.py",
3211 "from e import fe\n\ndef fd():\n return fe()\n",
3212 );
3213 create_test_file(&temp, "e.py", "def fe():\n return 42\n");
3214
3215 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3216 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3217
3218 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3219 let martin_report = compute_martin_metrics_from_deps(
3220 &deps_report,
3221 &MartinOptions {
3222 top: 2,
3223 cycles_only: false,
3224 },
3225 );
3226
3227 assert!(
3228 martin_report.metrics.len() <= 2,
3229 "top 2 should limit metrics to at most 2, got {}",
3230 martin_report.metrics.len()
3231 );
3232 assert!(
3234 martin_report.modules_analyzed >= 3,
3235 "modules_analyzed should reflect total (not filtered), got {}",
3236 martin_report.modules_analyzed
3237 );
3238 }
3239
3240 #[test]
3241 fn test_pair_mode_unchanged() {
3242 let temp = TempDir::new().unwrap();
3244
3245 let path_a = create_test_file(&temp, "a.py", "def standalone_a():\n return 1\n");
3246 let path_b = create_test_file(&temp, "b.py", "def standalone_b():\n return 2\n");
3247
3248 let args = CouplingArgs {
3249 path_a: path_a.clone(),
3250 path_b: Some(path_b.clone()),
3251 timeout: 30,
3252 project_root: None,
3253 max_pairs: 20,
3254 top: 3,
3255 cycles_only: true,
3256 lang: None,
3257 include_tests: false,
3258 };
3259
3260 let result = run(args, OutputFormat::Json);
3262 assert!(
3263 result.is_ok(),
3264 "pair mode with new flags should still work: {:?}",
3265 result.err()
3266 );
3267 }
3268
3269 #[test]
3270 fn test_project_mode_empty_dir() {
3271 let temp = TempDir::new().unwrap();
3274
3275 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3276 use tldr_core::quality::coupling::MartinMetricsReport;
3277
3278 let deps_result = analyze_dependencies(temp.path(), &DepsOptions::default());
3279 match deps_result {
3282 Err(_) => {
3283 let empty_report = MartinMetricsReport::default();
3284 let text = format_martin_text(&empty_report);
3285 assert!(
3286 text.contains("No modules found"),
3287 "empty report should say 'No modules found': {}",
3288 text
3289 );
3290 }
3291 Ok(deps_report) => {
3292 use tldr_core::quality::coupling::{
3294 compute_martin_metrics_from_deps, MartinOptions,
3295 };
3296 let martin_report = compute_martin_metrics_from_deps(
3297 &deps_report,
3298 &MartinOptions {
3299 top: 0,
3300 cycles_only: false,
3301 },
3302 );
3303 assert_eq!(
3304 martin_report.modules_analyzed, 0,
3305 "empty dir should have 0 modules"
3306 );
3307 let text = format_martin_text(&martin_report);
3308 assert!(
3309 text.contains("No modules found"),
3310 "empty dir text should say 'No modules found': {}",
3311 text
3312 );
3313 }
3314 }
3315 }
3316
3317 #[test]
3318 fn test_project_mode_single_file() {
3319 let temp = TempDir::new().unwrap();
3320
3321 create_test_file(&temp, "only.py", "def lonely():\n return 1\n");
3322
3323 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3324 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3325
3326 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3327 let martin_report = compute_martin_metrics_from_deps(
3328 &deps_report,
3329 &MartinOptions {
3330 top: 0,
3331 cycles_only: false,
3332 },
3333 );
3334
3335 assert!(
3337 martin_report.modules_analyzed >= 1,
3338 "single file should produce at least 1 module, got {}",
3339 martin_report.modules_analyzed
3340 );
3341 }
3342
3343 #[test]
3348 fn test_format_martin_json_schema() {
3349 use serde_json::Value;
3351 use tldr_core::quality::coupling::{
3352 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3353 };
3354
3355 let report = MartinMetricsReport {
3356 schema_version: "1.0".to_string(),
3357 modules_analyzed: 1,
3358 metrics: vec![MartinModuleMetrics {
3359 module: PathBuf::from("a.py"),
3360 ca: 0,
3361 ce: 0,
3362 instability: 0.0,
3363 in_cycle: false,
3364 }],
3365 cycles: vec![],
3366 summary: MartinSummary {
3367 avg_instability: 0.0,
3368 total_cycles: 0,
3369 most_stable: Some(PathBuf::from("a.py")),
3370 most_unstable: Some(PathBuf::from("a.py")),
3371 },
3372 };
3373
3374 let json_str = serde_json::to_string_pretty(&report).unwrap();
3375 let parsed: Value = serde_json::from_str(&json_str).unwrap();
3376
3377 assert_eq!(
3378 parsed["schema_version"].as_str(),
3379 Some("1.0"),
3380 "JSON should contain schema_version=1.0, got: {}",
3381 json_str
3382 );
3383 }
3384
3385 #[test]
3386 fn test_project_mode_top_and_cycles_combined() {
3387 let temp = TempDir::new().unwrap();
3389
3390 create_test_file(
3393 &temp,
3394 "a.py",
3395 "from b import fb\n\ndef fa():\n return fb()\n",
3396 );
3397 create_test_file(
3398 &temp,
3399 "b.py",
3400 "from a import fa\nfrom c import fc\n\ndef fb():\n return fa() + fc()\n",
3401 );
3402 create_test_file(
3403 &temp,
3404 "c.py",
3405 "from b import fb\n\ndef fc():\n return fb()\n",
3406 );
3407 create_test_file(&temp, "d.py", "def fd():\n return 42\n");
3408
3409 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3410 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3411
3412 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3413 let martin_report = compute_martin_metrics_from_deps(
3414 &deps_report,
3415 &MartinOptions {
3416 top: 2,
3417 cycles_only: true,
3418 },
3419 );
3420
3421 assert!(
3423 martin_report.metrics.len() <= 2,
3424 "top 2 + cycles_only should limit to at most 2 modules, got {}",
3425 martin_report.metrics.len()
3426 );
3427 for m in &martin_report.metrics {
3428 assert!(
3429 m.in_cycle,
3430 "all returned modules should be in_cycle, but {:?} is not",
3431 m.module
3432 );
3433 }
3434 }
3435
3436 #[test]
3437 fn test_coupling_args_lang_flag() {
3438 let args = CouplingArgs {
3440 path_a: PathBuf::from("src/a.ts"),
3441 path_b: Some(PathBuf::from("src/b.ts")),
3442 timeout: 30,
3443 project_root: None,
3444 max_pairs: 20,
3445 top: 0,
3446 cycles_only: false,
3447 lang: Some(TldrLanguage::TypeScript),
3448 include_tests: false,
3449 };
3450 assert_eq!(args.lang, Some(TldrLanguage::TypeScript));
3451
3452 let args_auto = CouplingArgs {
3454 path_a: PathBuf::from("src/a.py"),
3455 path_b: None,
3456 timeout: 30,
3457 project_root: None,
3458 max_pairs: 20,
3459 top: 0,
3460 cycles_only: false,
3461 lang: None,
3462 include_tests: false,
3463 };
3464 assert_eq!(args_auto.lang, None);
3465 }
3466}