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)]
75pub struct CouplingArgs {
76 pub path_a: PathBuf,
78
79 pub path_b: Option<PathBuf>,
81
82 #[arg(long, default_value = "30")]
84 pub timeout: u64,
85
86 #[arg(long)]
88 pub project_root: Option<PathBuf>,
89
90 #[arg(long, short = 'n', default_value = "20")]
92 pub max_pairs: usize,
93
94 #[arg(long, default_value = "0")]
96 pub top: usize,
97
98 #[arg(long)]
100 pub cycles_only: bool,
101
102 #[arg(long)]
104 pub include_tests: bool,
105
106 #[arg(long, short = 'l')]
108 pub lang: Option<TldrLanguage>,
109}
110
111#[derive(Debug, Clone)]
117pub struct ModuleInfo {
118 pub path: PathBuf,
120 pub defined_names: HashSet<String>,
122 pub imports: HashMap<String, String>,
124 pub calls: Vec<(String, String, u32)>,
126 pub function_count: u32,
128 pub param_types: HashMap<String, Vec<(String, String)>>,
137 pub method_calls: Vec<(String, String, String, u32)>,
143}
144
145impl ModuleInfo {
146 fn new(path: PathBuf) -> Self {
147 Self {
148 path,
149 defined_names: HashSet::new(),
150 imports: HashMap::new(),
151 calls: Vec::new(),
152 function_count: 0,
153 param_types: HashMap::new(),
154 method_calls: Vec::new(),
155 }
156 }
157}
158
159struct LangConfig {
165 function_kinds: &'static [&'static str],
167 class_kinds: &'static [&'static str],
169 import_kinds: &'static [&'static str],
171 call_kinds: &'static [&'static str],
173 func_name_field: &'static str,
175 use_name_field: bool,
177 recurse_into_classes: bool,
179}
180
181fn lang_config_for(lang: TldrLanguage) -> LangConfig {
182 match lang {
183 TldrLanguage::Python => LangConfig {
184 function_kinds: &["function_definition", "async_function_definition"],
185 class_kinds: &["class_definition"],
186 import_kinds: &["import_statement", "import_from_statement"],
187 call_kinds: &["call"],
188 func_name_field: "name",
189 use_name_field: true,
190 recurse_into_classes: false,
191 },
192 TldrLanguage::Go => LangConfig {
193 function_kinds: &["function_declaration", "method_declaration"],
194 class_kinds: &["type_declaration"],
195 import_kinds: &["import_declaration"],
196 call_kinds: &["call_expression"],
197 func_name_field: "name",
198 use_name_field: true,
199 recurse_into_classes: false,
200 },
201 TldrLanguage::Rust => LangConfig {
202 function_kinds: &["function_item"],
203 class_kinds: &["struct_item", "enum_item", "trait_item", "impl_item"],
204 import_kinds: &["use_declaration"],
205 call_kinds: &["call_expression"],
206 func_name_field: "name",
207 use_name_field: true,
208 recurse_into_classes: true,
209 },
210 TldrLanguage::TypeScript | TldrLanguage::JavaScript => LangConfig {
211 function_kinds: &[
212 "function_declaration",
213 "method_definition",
214 "arrow_function",
215 ],
216 class_kinds: &["class_declaration"],
217 import_kinds: &["import_statement"],
218 call_kinds: &["call_expression"],
219 func_name_field: "name",
220 use_name_field: true,
221 recurse_into_classes: false,
222 },
223 TldrLanguage::Java => LangConfig {
224 function_kinds: &["method_declaration", "constructor_declaration"],
225 class_kinds: &["class_declaration", "interface_declaration"],
226 import_kinds: &["import_declaration"],
227 call_kinds: &["method_invocation"],
228 func_name_field: "name",
229 use_name_field: true,
230 recurse_into_classes: true,
231 },
232 TldrLanguage::C => LangConfig {
233 function_kinds: &["function_definition"],
234 class_kinds: &["struct_specifier", "enum_specifier"],
235 import_kinds: &["preproc_include"],
236 call_kinds: &["call_expression"],
237 func_name_field: "declarator",
238 use_name_field: true,
239 recurse_into_classes: false,
240 },
241 TldrLanguage::Cpp => LangConfig {
242 function_kinds: &["function_definition"],
243 class_kinds: &["class_specifier", "struct_specifier", "enum_specifier"],
244 import_kinds: &["preproc_include"],
245 call_kinds: &["call_expression"],
246 func_name_field: "declarator",
247 use_name_field: true,
248 recurse_into_classes: true,
249 },
250 TldrLanguage::Ruby => LangConfig {
251 function_kinds: &["method", "singleton_method"],
252 class_kinds: &["class", "module"],
253 import_kinds: &[], call_kinds: &["call", "command"],
255 func_name_field: "name",
256 use_name_field: true,
257 recurse_into_classes: true,
258 },
259 TldrLanguage::CSharp => LangConfig {
260 function_kinds: &["method_declaration", "constructor_declaration"],
261 class_kinds: &[
262 "class_declaration",
263 "interface_declaration",
264 "struct_declaration",
265 ],
266 import_kinds: &["using_directive"],
267 call_kinds: &["invocation_expression"],
268 func_name_field: "name",
269 use_name_field: true,
270 recurse_into_classes: true,
271 },
272 TldrLanguage::Php => LangConfig {
273 function_kinds: &["function_definition", "method_declaration"],
274 class_kinds: &["class_declaration", "interface_declaration"],
275 import_kinds: &["namespace_use_declaration"],
276 call_kinds: &["function_call_expression", "member_call_expression"],
277 func_name_field: "name",
278 use_name_field: true,
279 recurse_into_classes: true,
280 },
281 TldrLanguage::Scala => LangConfig {
282 function_kinds: &["function_definition"],
283 class_kinds: &["class_definition", "object_definition", "trait_definition"],
284 import_kinds: &["import_declaration"],
285 call_kinds: &["call_expression"],
286 func_name_field: "name",
287 use_name_field: true,
288 recurse_into_classes: true,
289 },
290 TldrLanguage::Elixir => LangConfig {
291 function_kinds: &["call"], class_kinds: &[],
293 import_kinds: &[], call_kinds: &["call"],
295 func_name_field: "",
296 use_name_field: false,
297 recurse_into_classes: false,
298 },
299 TldrLanguage::Lua | TldrLanguage::Luau => LangConfig {
300 function_kinds: &[
301 "function_declaration",
302 "local_function_declaration_statement",
303 ],
304 class_kinds: &[],
305 import_kinds: &[], call_kinds: &["function_call"],
307 func_name_field: "name",
308 use_name_field: true,
309 recurse_into_classes: false,
310 },
311 TldrLanguage::Ocaml => LangConfig {
312 function_kinds: &["let_binding", "value_definition"],
313 class_kinds: &["type_definition", "module_definition"],
314 import_kinds: &["open_statement"],
315 call_kinds: &["application"],
316 func_name_field: "",
317 use_name_field: false,
318 recurse_into_classes: false,
319 },
320 _ => LangConfig {
322 function_kinds: &["function_definition"],
323 class_kinds: &["class_definition"],
324 import_kinds: &["import_statement"],
325 call_kinds: &["call_expression"],
326 func_name_field: "name",
327 use_name_field: true,
328 recurse_into_classes: false,
329 },
330 }
331}
332
333fn detect_language(path: &Path) -> PatternsResult<TldrLanguage> {
335 TldrLanguage::from_path(path).ok_or_else(|| {
336 PatternsError::parse_error(
337 path,
338 format!(
339 "Unsupported file extension: {}",
340 path.extension()
341 .and_then(|e| e.to_str())
342 .unwrap_or("(none)")
343 ),
344 )
345 })
346}
347
348pub fn extract_module_info(path: &PathBuf, source: &str) -> PatternsResult<ModuleInfo> {
360 let lang = detect_language(path)?;
361
362 let ts_lang = ParserPool::get_ts_language(lang).ok_or_else(|| {
363 PatternsError::parse_error(path, format!("No tree-sitter grammar for {:?}", lang))
364 })?;
365
366 let mut parser = Parser::new();
367 parser
368 .set_language(&ts_lang)
369 .map_err(|e| PatternsError::parse_error(path, format!("Failed to set language: {}", e)))?;
370
371 let tree = parser
372 .parse(source, None)
373 .ok_or_else(|| PatternsError::parse_error(path, "Failed to parse source"))?;
374
375 let root = tree.root_node();
376 let config = lang_config_for(lang);
377 let mut info = ModuleInfo::new(path.clone());
378
379 extract_top_level_generic(&root, source, &mut info, &config, lang)?;
381
382 if matches!(
387 lang,
388 TldrLanguage::Go
389 | TldrLanguage::Java
390 | TldrLanguage::CSharp
391 | TldrLanguage::Php
392 | TldrLanguage::Scala
393 ) {
394 enrich_imports_from_qualified_calls(&mut info);
395 }
396
397 Ok(info)
398}
399
400fn enrich_imports_from_qualified_calls(info: &mut ModuleInfo) {
406 let mut new_imports: Vec<(String, String)> = Vec::new();
408
409 for (_caller, callee, _line) in &info.calls {
410 if info.imports.contains_key(callee) {
412 continue;
413 }
414 new_imports.push((callee.clone(), callee.clone()));
418 }
419
420 for (name, module) in new_imports {
421 info.imports.entry(name).or_insert(module);
422 }
423}
424
425fn extract_top_level_generic(
427 root: &Node,
428 source: &str,
429 info: &mut ModuleInfo,
430 config: &LangConfig,
431 lang: TldrLanguage,
432) -> PatternsResult<()> {
433 extract_definitions_recursive(root, source, info, config, lang, 0);
434 Ok(())
435}
436
437fn extract_definitions_recursive(
441 node: &Node,
442 source: &str,
443 info: &mut ModuleInfo,
444 config: &LangConfig,
445 lang: TldrLanguage,
446 depth: u32,
447) {
448 let mut cursor = node.walk();
449
450 for child in node.children(&mut cursor) {
451 let kind = child.kind();
452
453 if config.function_kinds.contains(&kind) {
455 if lang == TldrLanguage::Elixir && kind == "call" {
457 if let Some(name) = extract_elixir_def_name(&child, source) {
458 info.defined_names.insert(name.clone());
459 info.function_count += 1;
460 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
461 }
462 continue;
463 }
464
465 if let Some(name) = get_name_generic(&child, source, config, lang) {
466 info.defined_names.insert(name.clone());
467 info.function_count += 1;
468 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
469 if matches!(
474 lang,
475 TldrLanguage::Java
476 | TldrLanguage::CSharp
477 | TldrLanguage::Php
478 | TldrLanguage::Scala
479 ) {
480 let params = extract_param_types(&child, source, lang);
481 if !params.is_empty() {
482 info.param_types.insert(name.clone(), params);
483 }
484 extract_method_calls_with_receiver(
485 &child,
486 source,
487 &name,
488 &mut info.method_calls,
489 lang,
490 );
491 }
492 }
493 }
494 else if config.class_kinds.contains(&kind) {
496 if let Some(name) = get_name_generic(&child, source, config, lang) {
497 info.defined_names.insert(name);
498 }
499 if config.recurse_into_classes && depth < 3 {
501 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
502 }
503 }
504 else if config.import_kinds.contains(&kind) {
506 extract_imports_generic(&child, source, &mut info.imports, lang);
507 }
508 else if lang == TldrLanguage::Ruby && (kind == "call" || kind == "command") {
510 extract_ruby_require(&child, source, &mut info.imports);
511 }
512 else if is_body_container(kind, lang) {
514 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
515 }
516 }
517}
518
519fn is_body_container(kind: &str, lang: TldrLanguage) -> bool {
521 match lang {
522 TldrLanguage::Java => matches!(kind, "class_body" | "program"),
523 TldrLanguage::CSharp => matches!(
524 kind,
525 "namespace_declaration"
526 | "file_scoped_namespace_declaration"
527 | "declaration_list"
528 | "class_body"
529 ),
530 TldrLanguage::Php => matches!(kind, "declaration_list" | "class_body" | "program"),
531 TldrLanguage::Scala => matches!(kind, "template_body"),
532 TldrLanguage::Cpp => matches!(kind, "declaration_list"),
533 TldrLanguage::Ruby => matches!(kind, "body_statement" | "program"),
534 _ => false,
535 }
536}
537
538fn get_name_generic(
540 node: &Node,
541 source: &str,
542 config: &LangConfig,
543 _lang: TldrLanguage,
544) -> Option<String> {
545 if config.use_name_field && !config.func_name_field.is_empty() {
547 if let Some(name_node) = node.child_by_field_name(config.func_name_field) {
548 return Some(extract_leaf_identifier(&name_node, source));
550 }
551 }
552
553 let mut cursor = node.walk();
555 for child in node.children(&mut cursor) {
556 if child.kind() == "identifier" || child.kind() == "name" {
557 return Some(node_text(&child, source));
558 }
559 }
560
561 None
562}
563
564fn extract_leaf_identifier(node: &Node, source: &str) -> String {
568 if node.kind() == "identifier" || node.kind() == "name" || node.child_count() == 0 {
569 return node_text(node, source);
570 }
571
572 let mut cursor = node.walk();
574 for child in node.children(&mut cursor) {
575 if child.kind() == "identifier" || child.kind() == "name" {
576 return node_text(&child, source);
577 }
578 let result = extract_leaf_identifier(&child, source);
580 if !result.is_empty() {
581 return result;
582 }
583 }
584
585 node_text(node, source)
586}
587
588fn extract_elixir_def_name(node: &Node, source: &str) -> Option<String> {
590 let mut cursor = node.walk();
593 for child in node.children(&mut cursor) {
594 let text = node_text(&child, source);
595 if text == "def" || text == "defp" {
596 if let Some(args) = child.next_sibling() {
598 return get_first_identifier(&args, source);
599 }
600 }
601 }
602 None
603}
604
605fn get_first_identifier(node: &Node, source: &str) -> Option<String> {
607 if node.kind() == "identifier" || node.kind() == "atom" {
608 return Some(node_text(node, source));
609 }
610 let mut cursor = node.walk();
611 for child in node.children(&mut cursor) {
612 if let Some(id) = get_first_identifier(&child, source) {
613 return Some(id);
614 }
615 }
616 None
617}
618
619fn extract_imports_generic(
625 node: &Node,
626 source: &str,
627 imports: &mut HashMap<String, String>,
628 lang: TldrLanguage,
629) {
630 match lang {
631 TldrLanguage::Python => extract_python_imports(node, source, imports),
632 TldrLanguage::Go => extract_go_imports(node, source, imports),
633 TldrLanguage::Rust => extract_rust_imports(node, source, imports),
634 TldrLanguage::TypeScript | TldrLanguage::JavaScript => {
635 extract_ts_imports(node, source, imports)
636 }
637 TldrLanguage::Java => extract_java_imports(node, source, imports),
638 TldrLanguage::C | TldrLanguage::Cpp => extract_c_imports(node, source, imports),
639 TldrLanguage::CSharp => extract_csharp_imports(node, source, imports),
640 TldrLanguage::Php => extract_php_imports(node, source, imports),
641 TldrLanguage::Scala => extract_scala_imports(node, source, imports),
642 TldrLanguage::Ocaml => extract_ocaml_imports(node, source, imports),
643 _ => extract_fallback_imports(node, source, imports),
646 }
647}
648
649fn extract_python_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
651 let kind = node.kind();
652 if kind == "import_statement" {
653 let mut cursor = node.walk();
654 for child in node.children(&mut cursor) {
655 if child.kind() == "dotted_name" {
656 let module_name = node_text(&child, source);
657 imports.insert(module_name.clone(), module_name);
658 } else if child.kind() == "aliased_import" {
659 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
660 imports.insert(alias, name);
661 }
662 }
663 }
664 } else if kind == "import_from_statement" {
665 let mut module_name = String::new();
666 let mut found_import_keyword = false;
667
668 let mut cursor = node.walk();
670 for child in node.children(&mut cursor) {
671 if child.kind() == "import" {
672 found_import_keyword = true;
673 continue;
674 }
675 if !found_import_keyword {
676 match child.kind() {
677 "dotted_name" | "relative_import" | "import_prefix" => {
678 module_name = node_text(&child, source);
679 }
680 _ => {}
681 }
682 }
683 }
684
685 let mut cursor2 = node.walk();
687 found_import_keyword = false;
688 for child in node.children(&mut cursor2) {
689 if child.kind() == "import" {
690 found_import_keyword = true;
691 continue;
692 }
693 if !found_import_keyword {
694 continue;
695 }
696 match child.kind() {
697 "dotted_name" | "identifier" => {
698 let name = node_text(&child, source);
699 imports.insert(name, module_name.clone());
700 }
701 "aliased_import" => {
702 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
703 imports.insert(alias, module_name.clone());
704 imports.insert(name, module_name.clone());
705 }
706 }
707 "wildcard_import" => {
708 imports.insert("*".to_string(), module_name.clone());
709 }
710 _ => {
711 extract_import_names_recursive(&child, source, &module_name, imports);
712 }
713 }
714 }
715 }
716}
717
718fn extract_import_names_recursive(
720 node: &Node,
721 source: &str,
722 module_name: &str,
723 imports: &mut HashMap<String, String>,
724) {
725 match node.kind() {
726 "dotted_name" | "identifier" => {
727 let name = node_text(node, source);
728 imports.insert(name, module_name.to_string());
729 }
730 "aliased_import" => {
731 if let (Some(name), Some(alias)) = extract_aliased_import(node, source) {
732 imports.insert(alias, module_name.to_string());
733 imports.insert(name, module_name.to_string());
734 }
735 }
736 _ => {
737 let mut cursor = node.walk();
738 for child in node.children(&mut cursor) {
739 extract_import_names_recursive(&child, source, module_name, imports);
740 }
741 }
742 }
743}
744
745fn extract_aliased_import(node: &Node, source: &str) -> (Option<String>, Option<String>) {
747 let mut name = None;
748 let mut alias = None;
749 let mut cursor = node.walk();
750
751 for child in node.children(&mut cursor) {
752 match child.kind() {
753 "dotted_name" | "identifier" => {
754 if name.is_none() {
755 name = Some(node_text(&child, source));
756 } else {
757 alias = Some(node_text(&child, source));
758 }
759 }
760 _ => {}
761 }
762 }
763
764 (name, alias)
765}
766
767fn extract_go_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
769 let mut stack = vec![*node];
771 while let Some(n) = stack.pop() {
772 if n.kind() == "import_spec" {
773 let path_node = n.child_by_field_name("path");
775 let name_node = n.child_by_field_name("name");
776
777 if let Some(path) = path_node {
778 let raw = node_text(&path, source);
779 let module_path = raw.trim_matches('"').to_string();
780 let short_name = if let Some(alias) = name_node {
782 node_text(&alias, source)
783 } else {
784 module_path
785 .rsplit('/')
786 .next()
787 .unwrap_or(&module_path)
788 .to_string()
789 };
790 imports.insert(short_name, module_path);
791 }
792 } else {
793 let mut cursor = n.walk();
794 for child in n.children(&mut cursor) {
795 stack.push(child);
796 }
797 }
798 }
799}
800
801fn extract_rust_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
803 let text = node_text(node, source);
805 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
807
808 if let Some(last) = trimmed.rsplit("::").next() {
810 if last.starts_with('{') {
811 let base = trimmed.rsplit_once("::").map(|x| x.0).unwrap_or("");
813 let items = last.trim_matches(|c| c == '{' || c == '}');
814 for item in items.split(',') {
815 let item = item.trim();
816 if !item.is_empty() {
817 imports.insert(item.to_string(), base.to_string());
818 }
819 }
820 } else {
821 imports.insert(last.to_string(), trimmed.to_string());
822 }
823 }
824}
825
826fn extract_ts_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
828 let mut module_path = String::new();
830 let mut cursor = node.walk();
831
832 if let Some(src) = node.child_by_field_name("source") {
834 let raw = node_text(&src, source);
835 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
836 } else {
837 for child in node.children(&mut cursor) {
839 if child.kind() == "string" {
840 let raw = node_text(&child, source);
841 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
842 }
843 }
844 }
845
846 let mut cursor2 = node.walk();
848 for child in node.children(&mut cursor2) {
849 match child.kind() {
850 "import_clause" | "named_imports" | "import_specifier" => {
851 collect_identifiers_recursive(&child, source, &module_path, imports);
852 }
853 "namespace_import" => {
854 if let Some(name) = child.child_by_field_name("name") {
856 imports.insert(node_text(&name, source), module_path.clone());
857 } else {
858 let mut inner = child.walk();
860 let mut last_id = None;
861 for c in child.children(&mut inner) {
862 if c.kind() == "identifier" {
863 last_id = Some(node_text(&c, source));
864 }
865 }
866 if let Some(id) = last_id {
867 imports.insert(id, module_path.clone());
868 }
869 }
870 }
871 "identifier" => {
872 imports.insert(node_text(&child, source), module_path.clone());
873 }
874 _ => {}
875 }
876 }
877}
878
879fn extract_java_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
881 let text = node_text(node, source);
883 let trimmed = text
884 .trim_start_matches("import ")
885 .trim_start_matches("static ")
886 .trim_end_matches(';')
887 .trim();
888
889 if let Some(last) = trimmed.rsplit('.').next() {
890 imports.insert(last.to_string(), trimmed.to_string());
891 }
892}
893
894fn extract_c_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
896 let mut cursor = node.walk();
898 for child in node.children(&mut cursor) {
899 let kind = child.kind();
900 if kind == "system_lib_string" || kind == "string_literal" || kind == "string_content" {
901 let raw = node_text(&child, source);
902 let header = raw
903 .trim_matches(|c| c == '<' || c == '>' || c == '"')
904 .to_string();
905 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
907 imports.insert(short, header);
908 }
909 }
910
911 if let Some(path) = node.child_by_field_name("path") {
913 let raw = node_text(&path, source);
914 let header = raw
915 .trim_matches(|c| c == '<' || c == '>' || c == '"')
916 .to_string();
917 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
918 imports.insert(short, header);
919 }
920}
921
922fn extract_csharp_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
924 let text = node_text(node, source);
925 let trimmed = text
926 .trim_start_matches("using ")
927 .trim_start_matches("static ")
928 .trim_end_matches(';')
929 .trim();
930
931 if let Some(last) = trimmed.rsplit('.').next() {
932 imports.insert(last.to_string(), trimmed.to_string());
933 }
934 if !trimmed.is_empty() {
936 imports.insert(trimmed.to_string(), trimmed.to_string());
937 }
938}
939
940fn extract_php_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
942 let text = node_text(node, source);
943 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
944
945 if trimmed.contains('{') {
947 if let Some((base, group)) = trimmed.split_once('{') {
948 let base = base.trim_end_matches('\\');
949 let items = group.trim_end_matches('}');
950 for item in items.split(',') {
951 let item = item.trim();
952 if !item.is_empty() {
953 imports.insert(item.to_string(), format!("{}\\{}", base, item));
954 }
955 }
956 }
957 } else if let Some(last) = trimmed.rsplit('\\').next() {
958 imports.insert(last.to_string(), trimmed.to_string());
959 }
960}
961
962fn extract_scala_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
964 let text = node_text(node, source);
965 let trimmed = text.trim_start_matches("import ").trim();
966
967 if let Some(last) = trimmed.rsplit('.').next() {
968 imports.insert(last.to_string(), trimmed.to_string());
969 }
970}
971
972fn extract_ocaml_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
974 let text = node_text(node, source);
975 let trimmed = text.trim_start_matches("open ").trim();
976 if !trimmed.is_empty() {
977 imports.insert(trimmed.to_string(), trimmed.to_string());
978 }
979}
980
981fn extract_fallback_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
983 let text = node_text(node, source).trim().to_string();
984 if !text.is_empty() {
985 imports.insert(text.clone(), text);
986 }
987}
988
989fn extract_ruby_require(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
991 let mut cursor = node.walk();
993 let mut method_name = String::new();
994
995 for child in node.children(&mut cursor) {
996 match child.kind() {
997 "identifier" | "constant" => {
998 let text = node_text(&child, source);
999 if text == "require" || text == "require_relative" {
1000 method_name = text;
1001 }
1002 }
1003 "argument_list" | "string" | "string_content" => {
1004 if !method_name.is_empty() {
1005 let raw = node_text(&child, source);
1006 let module = raw
1007 .trim_matches(|c: char| c == '\'' || c == '"' || c == '(' || c == ')')
1008 .to_string();
1009 if !module.is_empty() {
1010 let short = module.rsplit('/').next().unwrap_or(&module).to_string();
1011 imports.insert(short, module);
1012 }
1013 return;
1014 }
1015 }
1016 _ => {
1017 if !method_name.is_empty() {
1019 let mut inner = child.walk();
1020 for grandchild in child.children(&mut inner) {
1021 if grandchild.kind() == "string" || grandchild.kind() == "string_content" {
1022 let raw = node_text(&grandchild, source);
1023 let module = raw
1024 .trim_matches(|c: char| c == '\'' || c == '"')
1025 .to_string();
1026 if !module.is_empty() {
1027 let short =
1028 module.rsplit('/').next().unwrap_or(&module).to_string();
1029 imports.insert(short, module);
1030 }
1031 return;
1032 }
1033 }
1034 }
1035 }
1036 }
1037 }
1038}
1039
1040fn collect_identifiers_recursive(
1042 node: &Node,
1043 source: &str,
1044 module_path: &str,
1045 imports: &mut HashMap<String, String>,
1046) {
1047 if node.kind() == "identifier" {
1048 imports.insert(node_text(node, source), module_path.to_string());
1049 return;
1050 }
1051 let mut cursor = node.walk();
1052 for child in node.children(&mut cursor) {
1053 collect_identifiers_recursive(&child, source, module_path, imports);
1054 }
1055}
1056
1057fn extract_calls_generic(
1063 func_node: &Node,
1064 source: &str,
1065 caller_name: &str,
1066 calls: &mut Vec<(String, String, u32)>,
1067 config: &LangConfig,
1068 lang: TldrLanguage,
1069) {
1070 let mut stack = vec![*func_node];
1071
1072 while let Some(node) = stack.pop() {
1073 if config.call_kinds.contains(&node.kind()) {
1074 if let Some(callee) = extract_callee_generic(&node, source, lang) {
1075 let line = node.start_position().row as u32 + 1;
1076 calls.push((caller_name.to_string(), callee, line));
1077 }
1078 }
1079
1080 let mut cursor = node.walk();
1082 for child in node.children(&mut cursor) {
1083 stack.push(child);
1084 }
1085 }
1086}
1087
1088fn extract_param_types(
1102 func_node: &Node,
1103 source: &str,
1104 lang: TldrLanguage,
1105) -> Vec<(String, String)> {
1106 let mut result = Vec::new();
1107
1108 let params_field = match lang {
1110 TldrLanguage::Java | TldrLanguage::CSharp | TldrLanguage::Scala => func_node
1111 .child_by_field_name("parameters")
1112 .or_else(|| func_node.child_by_field_name("formal_parameters")),
1113 TldrLanguage::Php => func_node.child_by_field_name("parameters"),
1114 _ => None,
1115 };
1116 let Some(params_node) = params_field else {
1117 return result;
1118 };
1119
1120 let mut cursor = params_node.walk();
1121 for param in params_node.children(&mut cursor) {
1122 let pkind = param.kind();
1123 if !matches!(
1126 pkind,
1127 "formal_parameter"
1128 | "parameter"
1129 | "simple_parameter"
1130 | "spread_parameter"
1131 | "typed_parameter"
1132 | "class_parameter"
1133 ) {
1134 continue;
1135 }
1136
1137 let type_node = param.child_by_field_name("type");
1139 let type_name = type_node
1140 .map(|t| extract_leaf_identifier(&t, source))
1141 .unwrap_or_default();
1142
1143 if type_name.is_empty() {
1144 continue;
1145 }
1146
1147 let name_node = param
1151 .child_by_field_name("name")
1152 .or_else(|| first_named_kind(¶m, "variable_name"));
1153 let Some(name_node) = name_node else {
1154 continue;
1155 };
1156 let mut param_name = node_text(&name_node, source);
1157 if let Some(stripped) = param_name.strip_prefix('$') {
1158 param_name = stripped.to_string();
1159 }
1160 if param_name.is_empty() {
1161 continue;
1162 }
1163
1164 result.push((param_name, type_name));
1165 }
1166
1167 result
1168}
1169
1170fn first_named_kind<'tree>(node: &Node<'tree>, target_kind: &str) -> Option<Node<'tree>> {
1173 let mut cursor = node.walk();
1174 for child in node.children(&mut cursor) {
1175 if child.kind() == target_kind {
1176 return Some(child);
1177 }
1178 }
1179 None
1180}
1181
1182fn extract_method_calls_with_receiver(
1191 func_node: &Node,
1192 source: &str,
1193 caller_name: &str,
1194 method_calls: &mut Vec<(String, String, String, u32)>,
1195 lang: TldrLanguage,
1196) {
1197 let mut stack = vec![*func_node];
1198 while let Some(node) = stack.pop() {
1199 let kind = node.kind();
1200 let is_method_call = match lang {
1201 TldrLanguage::Java => kind == "method_invocation",
1202 TldrLanguage::CSharp => kind == "invocation_expression",
1203 TldrLanguage::Php => kind == "member_call_expression",
1204 TldrLanguage::Scala => kind == "call_expression",
1205 _ => false,
1206 };
1207
1208 if is_method_call {
1209 if let Some((receiver, method)) = extract_receiver_and_method(&node, source, lang) {
1210 let line = node.start_position().row as u32 + 1;
1211 method_calls.push((caller_name.to_string(), receiver, method, line));
1212 }
1213 }
1214
1215 let mut cursor = node.walk();
1216 for child in node.children(&mut cursor) {
1217 stack.push(child);
1218 }
1219 }
1220}
1221
1222fn extract_receiver_and_method(
1228 call_node: &Node,
1229 source: &str,
1230 lang: TldrLanguage,
1231) -> Option<(String, String)> {
1232 match lang {
1233 TldrLanguage::Java => {
1234 let object = call_node.child_by_field_name("object")?;
1235 if object.kind() != "identifier" {
1239 return None;
1240 }
1241 let name = call_node.child_by_field_name("name")?;
1242 Some((node_text(&object, source), node_text(&name, source)))
1243 }
1244 TldrLanguage::CSharp => {
1245 let func = call_node.child_by_field_name("function")?;
1246 if func.kind() != "member_access_expression" {
1247 return None;
1248 }
1249 let object = func.child_by_field_name("expression")?;
1250 if object.kind() != "identifier" {
1251 return None;
1252 }
1253 let name = func.child_by_field_name("name")?;
1254 Some((node_text(&object, source), node_text(&name, source)))
1255 }
1256 TldrLanguage::Php => {
1257 let object = call_node.child_by_field_name("object")?;
1258 if object.kind() != "variable_name" {
1259 return None;
1260 }
1261 let mut recv = node_text(&object, source);
1263 if let Some(stripped) = recv.strip_prefix('$') {
1264 recv = stripped.to_string();
1265 }
1266 let name = call_node.child_by_field_name("name")?;
1267 Some((recv, node_text(&name, source)))
1268 }
1269 TldrLanguage::Scala => {
1270 let func = call_node.child_by_field_name("function")?;
1271 if func.kind() != "field_expression" {
1272 return None;
1273 }
1274 let object = func.child_by_field_name("value")?;
1275 if object.kind() != "identifier" {
1276 return None;
1277 }
1278 let name = func.child_by_field_name("field")?;
1279 Some((node_text(&object, source), node_text(&name, source)))
1280 }
1281 _ => None,
1282 }
1283}
1284
1285fn extract_callee_generic(call_node: &Node, source: &str, lang: TldrLanguage) -> Option<String> {
1293 match lang {
1294 TldrLanguage::Java => {
1295 if let Some(name) = call_node.child_by_field_name("name") {
1297 return Some(node_text(&name, source));
1298 }
1299 }
1300 TldrLanguage::Go => {
1301 if let Some(func) = call_node.child_by_field_name("function") {
1303 match func.kind() {
1304 "identifier" => return Some(node_text(&func, source)),
1305 "selector_expression" => {
1306 if let Some(field) = func.child_by_field_name("field") {
1308 return Some(node_text(&field, source));
1309 }
1310 }
1311 _ => return Some(node_text(&func, source)),
1312 }
1313 }
1314 }
1315 TldrLanguage::Php => {
1316 if let Some(func) = call_node.child_by_field_name("function") {
1318 return Some(extract_leaf_identifier(&func, source));
1319 }
1320 if let Some(name) = call_node.child_by_field_name("name") {
1321 return Some(node_text(&name, source));
1322 }
1323 }
1324 TldrLanguage::CSharp => {
1325 if let Some(func) = call_node.child_by_field_name("function") {
1327 return Some(extract_last_identifier(&func, source));
1328 }
1329 let mut cursor = call_node.walk();
1331 for child in call_node.children(&mut cursor) {
1332 if child.kind() == "member_access_expression" {
1333 if let Some(name) = child.child_by_field_name("name") {
1334 return Some(node_text(&name, source));
1335 }
1336 }
1337 if child.kind() == "identifier" {
1338 return Some(node_text(&child, source));
1339 }
1340 }
1341 }
1342 _ => {}
1343 }
1344
1345 let mut cursor = call_node.walk();
1347 for child in call_node.children(&mut cursor) {
1348 match child.kind() {
1349 "identifier" | "name" => {
1350 return Some(node_text(&child, source));
1351 }
1352 "attribute" | "member_expression" | "field_expression" | "selector_expression" => {
1353 return Some(extract_last_identifier(&child, source));
1355 }
1356 "scoped_identifier" | "qualified_identifier" => {
1357 return Some(extract_last_identifier(&child, source));
1359 }
1360 _ => {}
1361 }
1362 }
1363 None
1364}
1365
1366fn extract_last_identifier(node: &Node, source: &str) -> String {
1368 let mut last_id = node_text(node, source);
1369 let mut cursor = node.walk();
1370 for child in node.children(&mut cursor) {
1371 if child.kind() == "identifier"
1372 || child.kind() == "name"
1373 || child.kind() == "field_identifier"
1374 || child.kind() == "property_identifier"
1375 {
1376 last_id = node_text(&child, source);
1377 }
1378 }
1379 last_id
1380}
1381
1382fn node_text(node: &Node, source: &str) -> String {
1384 source[node.byte_range()].to_string()
1385}
1386
1387pub fn find_cross_calls(caller: &ModuleInfo, callee: &ModuleInfo) -> CrossCalls {
1398 let mut calls = Vec::new();
1399
1400 for (caller_func, callee_name, line) in &caller.calls {
1401 if caller.imports.contains_key(callee_name) && callee.defined_names.contains(callee_name) {
1405 calls.push(CrossCall {
1406 caller: caller_func.clone(),
1407 callee: callee_name.clone(),
1408 line: *line,
1409 });
1410 }
1411 }
1412
1413 for (caller_func, receiver, method, line) in &caller.method_calls {
1424 let Some(params) = caller.param_types.get(caller_func) else {
1425 continue;
1426 };
1427 let Some(type_name) = params
1428 .iter()
1429 .find(|(pname, _)| pname == receiver)
1430 .map(|(_, t)| t)
1431 else {
1432 continue;
1433 };
1434
1435 if callee.defined_names.contains(type_name) {
1445 let already = calls
1448 .iter()
1449 .any(|c: &CrossCall| c.caller == *caller_func && c.line == *line);
1450 if !already {
1451 calls.push(CrossCall {
1452 caller: caller_func.clone(),
1453 callee: format!("{}.{}", type_name, method),
1454 line: *line,
1455 });
1456 }
1457 }
1458 }
1459
1460 let count = calls.len() as u32;
1461 CrossCalls { calls, count }
1462}
1463
1464fn augment_with_project_call_graph(
1476 user_path_a: &Path,
1477 user_path_b: &Path,
1478 a_to_b: &mut CrossCalls,
1479 b_to_a: &mut CrossCalls,
1480 lang_hint: Option<TldrLanguage>,
1481) {
1482 let canon_a = std::fs::canonicalize(user_path_a).unwrap_or_else(|_| user_path_a.to_path_buf());
1486 let canon_b = std::fs::canonicalize(user_path_b).unwrap_or_else(|_| user_path_b.to_path_buf());
1487 let root = match common_ancestor(&canon_a, &canon_b) {
1488 Some(r) => r,
1489 None => return,
1490 };
1491
1492 let language = match lang_hint
1494 .or_else(|| TldrLanguage::from_path(&canon_a))
1495 .or_else(|| TldrLanguage::from_path(&canon_b))
1496 {
1497 Some(l) => l,
1498 None => return,
1499 };
1500
1501 let graph = match tldr_core::build_project_call_graph(&root, language, None, true) {
1505 Ok(g) => g,
1506 Err(_) => return,
1507 };
1508
1509 let suffix_a = relative_suffix(&canon_a, &root);
1517 let suffix_b = relative_suffix(&canon_b, &root);
1518
1519 let existing_a_to_b: HashSet<(String, String)> = a_to_b
1525 .calls
1526 .iter()
1527 .map(|c| (c.caller.clone(), c.callee.clone()))
1528 .collect();
1529 let existing_b_to_a: HashSet<(String, String)> = b_to_a
1530 .calls
1531 .iter()
1532 .map(|c| (c.caller.clone(), c.callee.clone()))
1533 .collect();
1534
1535 for edge in graph.edges() {
1536 let src = edge.src_file.to_string_lossy();
1537 let dst = edge.dst_file.to_string_lossy();
1538
1539 let src_is_a = path_matches(&src, &suffix_a);
1540 let src_is_b = path_matches(&src, &suffix_b);
1541 let dst_is_a = path_matches(&dst, &suffix_a);
1542 let dst_is_b = path_matches(&dst, &suffix_b);
1543
1544 if src_is_a && dst_is_b {
1545 let caller = edge.src_func.clone();
1546 let callee = edge.dst_func.clone();
1547 if !existing_a_to_b.contains(&(caller.clone(), callee.clone())) {
1548 a_to_b.calls.push(CrossCall {
1549 caller,
1550 callee,
1551 line: 0,
1552 });
1553 a_to_b.count = a_to_b.count.saturating_add(1);
1554 }
1555 } else if src_is_b && dst_is_a {
1556 let caller = edge.src_func.clone();
1557 let callee = edge.dst_func.clone();
1558 if !existing_b_to_a.contains(&(caller.clone(), callee.clone())) {
1559 b_to_a.calls.push(CrossCall {
1560 caller,
1561 callee,
1562 line: 0,
1563 });
1564 b_to_a.count = b_to_a.count.saturating_add(1);
1565 }
1566 }
1567 }
1568}
1569
1570fn common_ancestor(a: &Path, b: &Path) -> Option<PathBuf> {
1573 let comps_a: Vec<_> = a.components().collect();
1574 let comps_b: Vec<_> = b.components().collect();
1575 let mut common = PathBuf::new();
1576 for (ca, cb) in comps_a.iter().zip(comps_b.iter()) {
1577 if ca == cb {
1578 common.push(ca.as_os_str());
1579 } else {
1580 break;
1581 }
1582 }
1583 if common.as_os_str().is_empty() {
1584 return None;
1585 }
1586 if common.is_file() {
1589 return common.parent().map(|p| p.to_path_buf());
1590 }
1591 Some(common)
1592}
1593
1594fn relative_suffix(file: &Path, root: &Path) -> String {
1598 file.strip_prefix(root)
1599 .map(|p| p.to_string_lossy().replace('\\', "/"))
1600 .unwrap_or_else(|_| {
1601 file.file_name()
1602 .map(|n| n.to_string_lossy().to_string())
1603 .unwrap_or_default()
1604 })
1605}
1606
1607fn path_matches(edge_path: &str, suffix: &str) -> bool {
1619 if suffix.is_empty() {
1620 return false;
1621 }
1622 edge_path.replace('\\', "/") == suffix
1623}
1624
1625pub fn compute_coupling_score(a_to_b: u32, b_to_a: u32, funcs_a: u32, funcs_b: u32) -> f64 {
1640 let total_funcs = funcs_a.saturating_add(funcs_b);
1641 if total_funcs == 0 {
1642 return 0.0;
1643 }
1644
1645 let cross_calls = a_to_b.saturating_add(b_to_a);
1646 let denominator = (total_funcs as f64) * 2.0;
1647
1648 (cross_calls as f64 / denominator).min(1.0)
1649}
1650
1651pub fn format_martin_text(report: &tldr_core::quality::coupling::MartinMetricsReport) -> String {
1660 let mut output = String::new();
1661
1662 output.push_str("Martin Coupling Metrics (project-wide)\n\n");
1663
1664 if report.metrics.is_empty() {
1665 output.push_str("No modules found.\n");
1666 return output;
1667 }
1668
1669 let max_path_len = report
1671 .metrics
1672 .iter()
1673 .map(|m| m.module.to_string_lossy().len())
1674 .max()
1675 .unwrap_or(6)
1676 .clamp(6, 40);
1677
1678 output.push_str(&format!(
1680 " {:<width$} | {:>2} | {:>2} | {:>6} | Cycle?\n",
1681 "Module",
1682 "Ca",
1683 "Ce",
1684 "I",
1685 width = max_path_len,
1686 ));
1687 output.push_str(&format!(
1688 "-{}-+----+----+--------+-------\n",
1689 "-".repeat(max_path_len),
1690 ));
1691
1692 for m in &report.metrics {
1694 let path_display = m.module.to_string_lossy();
1695 let truncated_path = if path_display.len() > max_path_len {
1696 format!(
1697 "...{}",
1698 &path_display[path_display.len() - (max_path_len - 3)..]
1699 )
1700 } else {
1701 path_display.to_string()
1702 };
1703
1704 let cycle_str = if m.in_cycle { "yes" } else { "--" };
1705
1706 output.push_str(&format!(
1707 " {:<width$} | {:>2} | {:>2} | {:.2} | {}\n",
1708 truncated_path,
1709 m.ca,
1710 m.ce,
1711 m.instability,
1712 cycle_str,
1713 width = max_path_len,
1714 ));
1715 }
1716
1717 output.push_str(&format!(
1719 "\nSummary: {} modules, {} cycles detected, avg instability: {:.2}\n",
1720 report.modules_analyzed, report.summary.total_cycles, report.summary.avg_instability,
1721 ));
1722
1723 if !report.cycles.is_empty() {
1725 output.push_str("\nCycles:\n");
1726 for (i, cycle) in report.cycles.iter().enumerate() {
1727 let path_strs: Vec<String> = cycle
1728 .path
1729 .iter()
1730 .map(|p| p.to_string_lossy().to_string())
1731 .collect();
1732 output.push_str(&format!(
1733 " {}. {} (length {})\n",
1734 i + 1,
1735 path_strs.join(" -> "),
1736 cycle.length,
1737 ));
1738 }
1739 }
1740
1741 output
1742}
1743
1744pub fn format_coupling_text(report: &CouplingReport) -> String {
1746 let mut lines = Vec::new();
1747
1748 lines.push(format!(
1749 "Coupling Analysis: {} <-> {}",
1750 report.path_a, report.path_b
1751 ));
1752 lines.push(String::new());
1753 lines.push(format!(
1754 "Score: {:.2} ({})",
1755 report.coupling_score, report.verdict
1756 ));
1757 lines.push(format!("Total cross-module calls: {}", report.total_calls));
1758 lines.push(String::new());
1759
1760 lines.push(format!(
1762 "Calls from {} to {}:",
1763 report.path_a, report.path_b
1764 ));
1765 if report.a_to_b.calls.is_empty() {
1766 lines.push(" (none)".to_string());
1767 } else {
1768 for call in &report.a_to_b.calls {
1769 lines.push(format!(
1770 " {} -> {} (line {})",
1771 call.caller, call.callee, call.line
1772 ));
1773 }
1774 }
1775 lines.push(String::new());
1776
1777 lines.push(format!(
1779 "Calls from {} to {}:",
1780 report.path_b, report.path_a
1781 ));
1782 if report.b_to_a.calls.is_empty() {
1783 lines.push(" (none)".to_string());
1784 } else {
1785 for call in &report.b_to_a.calls {
1786 lines.push(format!(
1787 " {} -> {} (line {})",
1788 call.caller, call.callee, call.line
1789 ));
1790 }
1791 }
1792
1793 lines.join("\n")
1794}
1795
1796pub fn run(args: CouplingArgs, format: OutputFormat) -> Result<()> {
1808 match args.path_b {
1810 Some(ref _path_b) => run_pair_mode(&args, format),
1811 None if args.path_a.is_dir() => run_project_mode(&args, format),
1812 None => {
1813 Err(anyhow::anyhow!(
1815 "For pair mode, provide two file paths: tldr coupling <file_a> <file_b>\n\
1816 For project-wide mode, provide a directory: tldr coupling <directory>"
1817 ))
1818 }
1819 }
1820}
1821
1822fn run_pair_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1824 let start = Instant::now();
1825 let timeout = Duration::from_secs(args.timeout);
1826
1827 let path_b_ref = args.path_b.as_ref().expect("pair mode requires path_b");
1828
1829 let path_a = if let Some(ref root) = args.project_root {
1831 validate_file_path_in_project(&args.path_a, root)?
1832 } else {
1833 validate_file_path(&args.path_a)?
1834 };
1835
1836 let path_b = if let Some(ref root) = args.project_root {
1837 validate_file_path_in_project(path_b_ref, root)?
1838 } else {
1839 validate_file_path(path_b_ref)?
1840 };
1841
1842 if start.elapsed() > timeout {
1844 return Err(PatternsError::Timeout {
1845 timeout_secs: args.timeout,
1846 }
1847 .into());
1848 }
1849
1850 let source_a = read_file_safe(&path_a)?;
1852 let source_b = read_file_safe(&path_b)?;
1853
1854 if start.elapsed() > timeout {
1856 return Err(PatternsError::Timeout {
1857 timeout_secs: args.timeout,
1858 }
1859 .into());
1860 }
1861
1862 let user_path_a = args.path_a.display().to_string();
1868 let user_path_b = path_b_ref.display().to_string();
1869
1870 if path_a == path_b {
1872 let report = CouplingReport {
1873 path_a: user_path_a.clone(),
1874 path_b: user_path_b.clone(),
1875 a_to_b: CrossCalls::default(),
1876 b_to_a: CrossCalls::default(),
1877 total_calls: 0,
1878 coupling_score: 1.0,
1879 verdict: CouplingVerdict::VeryHigh,
1880 };
1881
1882 output_pair_report(&report, format)?;
1883 return Ok(());
1884 }
1885
1886 let info_a = extract_module_info(&path_a, &source_a)?;
1888 let info_b = extract_module_info(&path_b, &source_b)?;
1889
1890 if start.elapsed() > timeout {
1892 return Err(PatternsError::Timeout {
1893 timeout_secs: args.timeout,
1894 }
1895 .into());
1896 }
1897
1898 let mut a_to_b = find_cross_calls(&info_a, &info_b);
1900 let mut b_to_a = find_cross_calls(&info_b, &info_a);
1901
1902 augment_with_project_call_graph(
1916 &args.path_a,
1917 path_b_ref,
1918 &mut a_to_b,
1919 &mut b_to_a,
1920 args.lang,
1921 );
1922
1923 let total_calls = a_to_b.count.saturating_add(b_to_a.count);
1925 let coupling_score = compute_coupling_score(
1926 a_to_b.count,
1927 b_to_a.count,
1928 info_a.function_count,
1929 info_b.function_count,
1930 );
1931 let verdict = CouplingVerdict::from_score(coupling_score);
1932
1933 let report = CouplingReport {
1935 path_a: user_path_a,
1936 path_b: user_path_b,
1937 a_to_b,
1938 b_to_a,
1939 total_calls,
1940 coupling_score,
1941 verdict,
1942 };
1943
1944 output_pair_report(&report, format)?;
1945
1946 Ok(())
1947}
1948
1949fn run_project_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1951 let mut pairwise_report = core_analyze_coupling(&args.path_a, None, Some(args.max_pairs))
1953 .map_err(|e| anyhow::anyhow!("coupling analysis failed: {}", e))?;
1954
1955 if !args.include_tests {
1957 pairwise_report
1958 .top_pairs
1959 .retain(|pair| !is_test_file(&pair.source) && !is_test_file(&pair.target));
1960 }
1961
1962 let martin_options = MartinOptions {
1964 top: args.top,
1965 cycles_only: args.cycles_only,
1966 };
1967 let mut martin_report = match analyze_dependencies(&args.path_a, &DepsOptions::default()) {
1968 Ok(deps_report) => compute_martin_metrics_from_deps(&deps_report, &martin_options),
1969 Err(_) => MartinMetricsReport::default(), };
1971
1972 if !args.include_tests {
1974 let pre_count = martin_report.metrics.len();
1975 martin_report.metrics.retain(|m| !is_test_file(&m.module));
1976 martin_report.modules_analyzed = martin_report.metrics.len();
1977
1978 if martin_report.metrics.len() < pre_count {
1980 if martin_report.metrics.is_empty() {
1981 martin_report.summary.avg_instability = 0.0;
1982 martin_report.summary.most_stable = None;
1983 martin_report.summary.most_unstable = None;
1984 } else {
1985 let sum: f64 = martin_report.metrics.iter().map(|m| m.instability).sum();
1986 martin_report.summary.avg_instability = sum / martin_report.metrics.len() as f64;
1987 martin_report.summary.most_stable = martin_report
1988 .metrics
1989 .iter()
1990 .min_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1991 .map(|m| m.module.clone());
1992 martin_report.summary.most_unstable = martin_report
1993 .metrics
1994 .iter()
1995 .max_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1996 .map(|m| m.module.clone());
1997 }
1998 martin_report
2000 .cycles
2001 .retain(|cycle| cycle.path.iter().all(|m| !is_test_file(m)));
2002 martin_report.summary.total_cycles = martin_report.cycles.len();
2003 }
2004 }
2005
2006 output_project_report_with_martin(&pairwise_report, &martin_report, format)?;
2007 Ok(())
2008}
2009
2010fn output_project_report_with_martin(
2012 pairwise_report: &CoreCouplingReport,
2013 martin_report: &MartinMetricsReport,
2014 format: OutputFormat,
2015) -> Result<()> {
2016 match format {
2017 OutputFormat::Text => {
2018 println!("{}", format_martin_text(martin_report));
2020 if !pairwise_report.top_pairs.is_empty() {
2021 println!("{}", format_coupling_project_text(pairwise_report));
2022 }
2023 }
2024 OutputFormat::Compact => {
2025 let combined = serde_json::json!({
2026 "martin_metrics": serde_json::to_value(martin_report)?,
2027 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
2028 });
2029 let json = serde_json::to_string(&combined)?;
2030 println!("{}", json);
2031 }
2032 _ => {
2033 let combined = serde_json::json!({
2034 "martin_metrics": serde_json::to_value(martin_report)?,
2035 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
2036 });
2037 let json = serde_json::to_string_pretty(&combined)?;
2038 println!("{}", json);
2039 }
2040 }
2041 Ok(())
2042}
2043
2044fn output_pair_report(report: &CouplingReport, format: OutputFormat) -> Result<()> {
2046 match format {
2047 OutputFormat::Text => {
2048 println!("{}", format_coupling_text(report));
2049 }
2050 OutputFormat::Compact => {
2051 let json = serde_json::to_string(report)?;
2052 println!("{}", json);
2053 }
2054 _ => {
2055 let json = serde_json::to_string_pretty(report)?;
2056 println!("{}", json);
2057 }
2058 }
2059 Ok(())
2060}
2061
2062pub fn format_coupling_project_text(report: &CoreCouplingReport) -> String {
2069 let mut output = String::new();
2070
2071 output.push_str(&format!(
2072 "{}\n\n",
2073 "Coupling Analysis (project-wide)".bold()
2074 ));
2075
2076 if report.top_pairs.is_empty() {
2077 output.push_str(&format!(
2078 "Summary: {} modules, 0 pairs analyzed\n",
2079 report.modules_analyzed,
2080 ));
2081 return output;
2082 }
2083
2084 let all_paths: Vec<&Path> = report
2086 .top_pairs
2087 .iter()
2088 .flat_map(|p| [p.source.as_path(), p.target.as_path()])
2089 .collect();
2090 let prefix = common_path_prefix(&all_paths);
2091
2092 output.push_str(&format!(
2094 " {:>5} {:>5} {:>7} {:>10} {}\n",
2095 "Score", "Calls", "Imports", "Verdict", "Source -> Target"
2096 ));
2097
2098 for pair in &report.top_pairs {
2100 let source_rel = strip_prefix_display(&pair.source, &prefix);
2101 let target_rel = strip_prefix_display(&pair.target, &prefix);
2102
2103 let verdict_str = match pair.verdict {
2104 CoreVerdict::Tight => "tight".red().bold().to_string(),
2105 CoreVerdict::Moderate => "moderate".yellow().to_string(),
2106 CoreVerdict::Loose => "loose".green().to_string(),
2107 };
2108
2109 let score_str = format!("{:.2}", pair.score);
2110 let score_colored = match pair.verdict {
2111 CoreVerdict::Tight => score_str.red().bold().to_string(),
2112 CoreVerdict::Moderate => score_str.yellow().to_string(),
2113 CoreVerdict::Loose => score_str.green().to_string(),
2114 };
2115
2116 output.push_str(&format!(
2117 " {:>5} {:>5} {:>7} {:>10} {} -> {}\n",
2118 score_colored, pair.call_count, pair.import_count, verdict_str, source_rel, target_rel,
2119 ));
2120 }
2121
2122 let avg_str = report
2124 .avg_coupling_score
2125 .map(|s| format!("{:.2}", s))
2126 .unwrap_or_else(|| "N/A".to_string());
2127
2128 output.push_str(&format!(
2129 "\nSummary: {} modules, {} pairs analyzed, {} tight, avg score: {}\n",
2130 report.modules_analyzed, report.pairs_analyzed, report.tight_coupling_count, avg_str,
2131 ));
2132
2133 if report.truncated == Some(true) {
2134 if let Some(total) = report.total_pairs {
2135 output.push_str(&format!(
2136 " (showing top {} of {} pairs)\n",
2137 report.top_pairs.len(),
2138 total,
2139 ));
2140 }
2141 }
2142
2143 output
2144}
2145
2146#[cfg(test)]
2151mod tests {
2152 use super::*;
2153 use std::fs;
2154 use tempfile::TempDir;
2155
2156 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
2158 let path = dir.path().join(name);
2159 fs::write(&path, content).unwrap();
2160 path
2161 }
2162
2163 #[test]
2168 fn test_compute_coupling_score_no_calls() {
2169 let score = compute_coupling_score(0, 0, 5, 5);
2170 assert_eq!(score, 0.0);
2171 }
2172
2173 #[test]
2174 fn test_compute_coupling_score_unidirectional() {
2175 let score = compute_coupling_score(2, 0, 5, 5);
2178 assert!((score - 0.1).abs() < 0.001);
2179 }
2180
2181 #[test]
2182 fn test_compute_coupling_score_bidirectional() {
2183 let score = compute_coupling_score(3, 2, 5, 5);
2186 assert!((score - 0.25).abs() < 0.001);
2187 }
2188
2189 #[test]
2190 fn test_compute_coupling_score_no_functions() {
2191 let score = compute_coupling_score(5, 5, 0, 0);
2192 assert_eq!(score, 0.0);
2193 }
2194
2195 #[test]
2196 fn test_compute_coupling_score_clamped() {
2197 let score = compute_coupling_score(100, 100, 1, 1);
2199 assert_eq!(score, 1.0);
2200 }
2201
2202 #[test]
2207 fn test_verdict_low() {
2208 assert_eq!(CouplingVerdict::from_score(0.0), CouplingVerdict::Low);
2209 assert_eq!(CouplingVerdict::from_score(0.1), CouplingVerdict::Low);
2210 assert_eq!(CouplingVerdict::from_score(0.19), CouplingVerdict::Low);
2211 }
2212
2213 #[test]
2214 fn test_verdict_moderate() {
2215 assert_eq!(CouplingVerdict::from_score(0.2), CouplingVerdict::Moderate);
2216 assert_eq!(CouplingVerdict::from_score(0.3), CouplingVerdict::Moderate);
2217 assert_eq!(CouplingVerdict::from_score(0.39), CouplingVerdict::Moderate);
2218 }
2219
2220 #[test]
2221 fn test_verdict_high() {
2222 assert_eq!(CouplingVerdict::from_score(0.4), CouplingVerdict::High);
2223 assert_eq!(CouplingVerdict::from_score(0.5), CouplingVerdict::High);
2224 assert_eq!(CouplingVerdict::from_score(0.59), CouplingVerdict::High);
2225 }
2226
2227 #[test]
2228 fn test_verdict_very_high() {
2229 assert_eq!(CouplingVerdict::from_score(0.6), CouplingVerdict::VeryHigh);
2230 assert_eq!(CouplingVerdict::from_score(0.8), CouplingVerdict::VeryHigh);
2231 assert_eq!(CouplingVerdict::from_score(1.0), CouplingVerdict::VeryHigh);
2232 }
2233
2234 #[test]
2239 fn test_extract_defined_names() {
2240 let source = r#"
2241def func_a():
2242 pass
2243
2244async def func_b():
2245 pass
2246
2247class MyClass:
2248 pass
2249"#;
2250 let temp = TempDir::new().unwrap();
2251 let path = create_test_file(&temp, "test.py", source);
2252 let info = extract_module_info(&path, source).unwrap();
2253
2254 assert!(info.defined_names.contains("func_a"));
2255 assert!(info.defined_names.contains("func_b"));
2256 assert!(info.defined_names.contains("MyClass"));
2257 assert_eq!(info.function_count, 2);
2258 }
2259
2260 #[test]
2261 fn test_extract_imports() {
2262 let source = r#"
2263import os
2264import sys as system
2265from pathlib import Path
2266from collections import defaultdict, Counter
2267from typing import List as L
2268"#;
2269 let temp = TempDir::new().unwrap();
2270 let path = create_test_file(&temp, "test.py", source);
2271 let info = extract_module_info(&path, source).unwrap();
2272
2273 assert!(info.imports.contains_key("os"));
2274 assert!(info.imports.contains_key("system"));
2275 assert!(info.imports.contains_key("Path"));
2276 assert!(info.imports.contains_key("defaultdict"));
2277 assert!(info.imports.contains_key("Counter"));
2278 }
2279
2280 #[test]
2281 fn test_extract_calls() {
2282 let source = r#"
2283def caller():
2284 result = helper()
2285 obj.method()
2286 other_func(1, 2, 3)
2287 return result
2288"#;
2289 let temp = TempDir::new().unwrap();
2290 let path = create_test_file(&temp, "test.py", source);
2291 let info = extract_module_info(&path, source).unwrap();
2292
2293 let callees: Vec<&str> = info
2295 .calls
2296 .iter()
2297 .map(|(_, callee, _)| callee.as_str())
2298 .collect();
2299 assert!(callees.contains(&"helper"));
2300 assert!(callees.contains(&"method"));
2301 assert!(callees.contains(&"other_func"));
2302 }
2303
2304 #[test]
2309 fn test_find_cross_calls_simple() {
2310 let temp = TempDir::new().unwrap();
2311
2312 let source_a = r#"
2314from module_b import helper
2315
2316def caller():
2317 return helper()
2318"#;
2319 let path_a = create_test_file(&temp, "module_a.py", source_a);
2320 let info_a = extract_module_info(&path_a, source_a).unwrap();
2321
2322 let source_b = r#"
2324def helper():
2325 return 42
2326"#;
2327 let path_b = create_test_file(&temp, "module_b.py", source_b);
2328 let info_b = extract_module_info(&path_b, source_b).unwrap();
2329
2330 let cross_calls = find_cross_calls(&info_a, &info_b);
2331
2332 assert_eq!(cross_calls.count, 1);
2333 assert_eq!(cross_calls.calls[0].caller, "caller");
2334 assert_eq!(cross_calls.calls[0].callee, "helper");
2335 }
2336
2337 #[test]
2338 fn test_find_cross_calls_no_import() {
2339 let temp = TempDir::new().unwrap();
2340
2341 let source_a = r#"
2343def caller():
2344 return helper()
2345"#;
2346 let path_a = create_test_file(&temp, "module_a.py", source_a);
2347 let info_a = extract_module_info(&path_a, source_a).unwrap();
2348
2349 let source_b = r#"
2351def helper():
2352 return 42
2353"#;
2354 let path_b = create_test_file(&temp, "module_b.py", source_b);
2355 let info_b = extract_module_info(&path_b, source_b).unwrap();
2356
2357 let cross_calls = find_cross_calls(&info_a, &info_b);
2358
2359 assert_eq!(cross_calls.count, 0);
2361 }
2362
2363 #[test]
2364 fn test_find_cross_calls_bidirectional() {
2365 let temp = TempDir::new().unwrap();
2366
2367 let source_a = r#"
2369from module_b import helper_b
2370
2371def func_a():
2372 return helper_b()
2373"#;
2374 let path_a = create_test_file(&temp, "module_a.py", source_a);
2375 let info_a = extract_module_info(&path_a, source_a).unwrap();
2376
2377 let source_b = r#"
2379from module_a import func_a
2380
2381def helper_b():
2382 return 42
2383
2384def caller_b():
2385 return func_a()
2386"#;
2387 let path_b = create_test_file(&temp, "module_b.py", source_b);
2388 let info_b = extract_module_info(&path_b, source_b).unwrap();
2389
2390 let a_to_b = find_cross_calls(&info_a, &info_b);
2391 let b_to_a = find_cross_calls(&info_b, &info_a);
2392
2393 assert_eq!(a_to_b.count, 1);
2394 assert_eq!(b_to_a.count, 1);
2395 }
2396
2397 #[test]
2402 fn test_format_coupling_text() {
2403 let report = CouplingReport {
2404 path_a: "src/auth.py".to_string(),
2405 path_b: "src/user.py".to_string(),
2406 a_to_b: CrossCalls {
2407 calls: vec![CrossCall {
2408 caller: "login".to_string(),
2409 callee: "get_user".to_string(),
2410 line: 10,
2411 }],
2412 count: 1,
2413 },
2414 b_to_a: CrossCalls::default(),
2415 total_calls: 1,
2416 coupling_score: 0.15,
2417 verdict: CouplingVerdict::Low,
2418 };
2419
2420 let text = format_coupling_text(&report);
2421
2422 assert!(text.contains("src/auth.py"));
2423 assert!(text.contains("src/user.py"));
2424 assert!(text.contains("0.15"));
2425 assert!(text.contains("low"));
2426 assert!(text.contains("login"));
2427 assert!(text.contains("get_user"));
2428 assert!(text.contains("line 10"));
2429 }
2430
2431 #[test]
2436 fn test_run_no_coupling() {
2437 let temp = TempDir::new().unwrap();
2438
2439 let source_a = r#"
2440def standalone_a():
2441 return 1
2442"#;
2443 let source_b = r#"
2444def standalone_b():
2445 return 2
2446"#;
2447
2448 let path_a = create_test_file(&temp, "a.py", source_a);
2449 let path_b = create_test_file(&temp, "b.py", source_b);
2450
2451 let args = CouplingArgs {
2452 path_a: path_a.clone(),
2453 path_b: Some(path_b.clone()),
2454 timeout: 30,
2455 project_root: None,
2456 max_pairs: 20,
2457 top: 0,
2458 cycles_only: false,
2459 lang: None,
2460 include_tests: false,
2461 };
2462
2463 let result = run(args, OutputFormat::Json);
2465 assert!(result.is_ok());
2466 }
2467
2468 #[test]
2469 fn test_run_with_coupling() {
2470 let temp = TempDir::new().unwrap();
2471
2472 let source_a = r#"
2473from b import helper
2474
2475def caller():
2476 return helper()
2477"#;
2478 let source_b = r#"
2479def helper():
2480 return 42
2481"#;
2482
2483 let path_a = create_test_file(&temp, "a.py", source_a);
2484 let path_b = create_test_file(&temp, "b.py", source_b);
2485
2486 let args = CouplingArgs {
2487 path_a: path_a.clone(),
2488 path_b: Some(path_b.clone()),
2489 timeout: 30,
2490 project_root: None,
2491 max_pairs: 20,
2492 top: 0,
2493 cycles_only: false,
2494 lang: None,
2495 include_tests: false,
2496 };
2497
2498 let result = run(args, OutputFormat::Json);
2499 assert!(result.is_ok());
2500 }
2501
2502 #[test]
2507 fn test_go_extract_module_info() {
2508 let source = r#"
2509package main
2510
2511import (
2512 "fmt"
2513 "myapp/utils"
2514)
2515
2516func Caller() {
2517 utils.Helper()
2518 fmt.Println("hello")
2519}
2520
2521func Standalone() int {
2522 return 42
2523}
2524"#;
2525 let temp = TempDir::new().unwrap();
2526 let path = create_test_file(&temp, "main.go", source);
2527 let info = extract_module_info(&path, source).unwrap();
2528
2529 assert!(info.defined_names.contains("Caller"), "missing Caller");
2531 assert!(
2532 info.defined_names.contains("Standalone"),
2533 "missing Standalone"
2534 );
2535 assert_eq!(info.function_count, 2);
2536
2537 assert!(
2539 info.imports.contains_key("fmt") || info.imports.values().any(|v| v.contains("fmt")),
2540 "missing fmt import: {:?}",
2541 info.imports
2542 );
2543 }
2544
2545 #[test]
2546 fn test_go_cross_calls() {
2547 let temp = TempDir::new().unwrap();
2548
2549 let source_a = r#"
2550package main
2551
2552import "myapp/pkg_b"
2553
2554func CallerA() {
2555 pkg_b.HelperB()
2556}
2557"#;
2558 let source_b = r#"
2559package pkg_b
2560
2561func HelperB() int {
2562 return 42
2563}
2564"#;
2565 let path_a = create_test_file(&temp, "a.go", source_a);
2566 let path_b = create_test_file(&temp, "b.go", source_b);
2567
2568 let info_a = extract_module_info(&path_a, source_a).unwrap();
2569 let info_b = extract_module_info(&path_b, source_b).unwrap();
2570
2571 let a_to_b = find_cross_calls(&info_a, &info_b);
2573 assert!(
2574 a_to_b.count >= 1,
2575 "expected cross-calls from A to B, got {}",
2576 a_to_b.count
2577 );
2578 }
2579
2580 #[test]
2581 fn test_rust_extract_module_info() {
2582 let source = r#"
2583use std::collections::HashMap;
2584use crate::module_b::helper;
2585
2586pub fn caller() {
2587 let _ = helper();
2588}
2589
2590fn standalone() -> i32 {
2591 42
2592}
2593"#;
2594 let temp = TempDir::new().unwrap();
2595 let path = create_test_file(&temp, "lib.rs", source);
2596 let info = extract_module_info(&path, source).unwrap();
2597
2598 assert!(info.defined_names.contains("caller"), "missing caller");
2600 assert!(
2601 info.defined_names.contains("standalone"),
2602 "missing standalone"
2603 );
2604 assert_eq!(info.function_count, 2);
2605
2606 assert!(
2608 !info.imports.is_empty(),
2609 "should have imports: {:?}",
2610 info.imports
2611 );
2612 }
2613
2614 #[test]
2615 fn test_typescript_extract_module_info() {
2616 let source = r#"
2617import { helper } from './module_b';
2618import * as utils from './utils';
2619
2620function caller(): void {
2621 helper();
2622 utils.doStuff();
2623}
2624
2625function standalone(): number {
2626 return 42;
2627}
2628"#;
2629 let temp = TempDir::new().unwrap();
2630 let path = create_test_file(&temp, "main.ts", source);
2631 let info = extract_module_info(&path, source).unwrap();
2632
2633 assert!(info.defined_names.contains("caller"), "missing caller");
2635 assert!(
2636 info.defined_names.contains("standalone"),
2637 "missing standalone"
2638 );
2639 assert_eq!(info.function_count, 2);
2640
2641 assert!(
2643 !info.imports.is_empty(),
2644 "should have imports: {:?}",
2645 info.imports
2646 );
2647 }
2648
2649 #[test]
2650 fn test_java_extract_module_info() {
2651 let source = r#"
2652import com.example.utils.Helper;
2653import java.util.List;
2654
2655public class Main {
2656 public void caller() {
2657 Helper.doWork();
2658 }
2659
2660 public int standalone() {
2661 return 42;
2662 }
2663}
2664"#;
2665 let temp = TempDir::new().unwrap();
2666 let path = create_test_file(&temp, "Main.java", source);
2667 let info = extract_module_info(&path, source).unwrap();
2668
2669 assert!(info.defined_names.contains("caller"), "missing caller");
2671 assert!(
2672 info.defined_names.contains("standalone"),
2673 "missing standalone"
2674 );
2675
2676 assert!(
2678 !info.imports.is_empty(),
2679 "should have imports: {:?}",
2680 info.imports
2681 );
2682 }
2683
2684 #[test]
2685 fn test_c_extract_module_info() {
2686 let source = r#"
2687#include <stdio.h>
2688#include "mylib.h"
2689
2690void caller() {
2691 helper();
2692 printf("hello\n");
2693}
2694
2695int standalone() {
2696 return 42;
2697}
2698"#;
2699 let temp = TempDir::new().unwrap();
2700 let path = create_test_file(&temp, "main.c", source);
2701 let info = extract_module_info(&path, source).unwrap();
2702
2703 assert!(info.defined_names.contains("caller"), "missing caller");
2705 assert!(
2706 info.defined_names.contains("standalone"),
2707 "missing standalone"
2708 );
2709 assert_eq!(info.function_count, 2);
2710
2711 assert!(
2713 !info.imports.is_empty(),
2714 "should have imports from #include: {:?}",
2715 info.imports
2716 );
2717 }
2718
2719 #[test]
2720 fn test_ruby_extract_module_info() {
2721 let source = r#"
2722require 'json'
2723require_relative 'helper'
2724
2725def caller
2726 helper_method
2727 JSON.parse("{}")
2728end
2729
2730def standalone
2731 42
2732end
2733"#;
2734 let temp = TempDir::new().unwrap();
2735 let path = create_test_file(&temp, "main.rb", source);
2736 let info = extract_module_info(&path, source).unwrap();
2737
2738 assert!(info.defined_names.contains("caller"), "missing caller");
2740 assert!(
2741 info.defined_names.contains("standalone"),
2742 "missing standalone"
2743 );
2744 assert_eq!(info.function_count, 2);
2745
2746 assert!(
2748 !info.imports.is_empty(),
2749 "should have imports from require: {:?}",
2750 info.imports
2751 );
2752 }
2753
2754 #[test]
2755 fn test_cpp_extract_module_info() {
2756 let source = r#"
2757#include <iostream>
2758#include "mylib.hpp"
2759
2760void caller() {
2761 helper();
2762 std::cout << "hello" << std::endl;
2763}
2764
2765int standalone() {
2766 return 42;
2767}
2768"#;
2769 let temp = TempDir::new().unwrap();
2770 let path = create_test_file(&temp, "main.cpp", source);
2771 let info = extract_module_info(&path, source).unwrap();
2772
2773 assert!(info.defined_names.contains("caller"), "missing caller");
2774 assert!(
2775 info.defined_names.contains("standalone"),
2776 "missing standalone"
2777 );
2778 assert_eq!(info.function_count, 2);
2779 assert!(
2780 !info.imports.is_empty(),
2781 "should have imports from #include: {:?}",
2782 info.imports
2783 );
2784 }
2785
2786 #[test]
2787 fn test_php_extract_module_info() {
2788 let source = r#"<?php
2789use App\Utils\Helper;
2790use Symfony\Component\Console\Command;
2791
2792function caller() {
2793 Helper::doWork();
2794}
2795
2796function standalone() {
2797 return 42;
2798}
2799"#;
2800 let temp = TempDir::new().unwrap();
2801 let path = create_test_file(&temp, "main.php", source);
2802 let info = extract_module_info(&path, source).unwrap();
2803
2804 assert!(info.defined_names.contains("caller"), "missing caller");
2805 assert!(
2806 info.defined_names.contains("standalone"),
2807 "missing standalone"
2808 );
2809 assert_eq!(info.function_count, 2);
2810 assert!(
2811 !info.imports.is_empty(),
2812 "should have imports from use: {:?}",
2813 info.imports
2814 );
2815 }
2816
2817 #[test]
2818 fn test_csharp_extract_module_info() {
2819 let source = r#"
2820using System;
2821using MyApp.Utils;
2822
2823public class Main {
2824 public void Caller() {
2825 Helper.DoWork();
2826 }
2827
2828 public int Standalone() {
2829 return 42;
2830 }
2831}
2832"#;
2833 let temp = TempDir::new().unwrap();
2834 let path = create_test_file(&temp, "Main.cs", source);
2835 let info = extract_module_info(&path, source).unwrap();
2836
2837 assert!(info.defined_names.contains("Caller"), "missing Caller");
2838 assert!(
2839 info.defined_names.contains("Standalone"),
2840 "missing Standalone"
2841 );
2842 assert!(
2843 !info.imports.is_empty(),
2844 "should have imports from using: {:?}",
2845 info.imports
2846 );
2847 }
2848
2849 #[test]
2850 fn test_run_go_coupling() {
2851 let temp = TempDir::new().unwrap();
2852
2853 let source_a = r#"
2854package main
2855
2856func standalone_a() int {
2857 return 1
2858}
2859"#;
2860 let source_b = r#"
2861package main
2862
2863func standalone_b() int {
2864 return 2
2865}
2866"#;
2867
2868 let path_a = create_test_file(&temp, "a.go", source_a);
2869 let path_b = create_test_file(&temp, "b.go", source_b);
2870
2871 let args = CouplingArgs {
2872 path_a: path_a.clone(),
2873 path_b: Some(path_b.clone()),
2874 timeout: 30,
2875 project_root: None,
2876 max_pairs: 20,
2877 top: 0,
2878 cycles_only: false,
2879 lang: None,
2880 include_tests: false,
2881 };
2882
2883 let result = run(args, OutputFormat::Json);
2884 assert!(
2885 result.is_ok(),
2886 "coupling should work for Go files: {:?}",
2887 result.err()
2888 );
2889 }
2890
2891 #[test]
2892 fn test_run_rust_coupling() {
2893 let temp = TempDir::new().unwrap();
2894
2895 let source_a = r#"
2896fn standalone_a() -> i32 {
2897 1
2898}
2899"#;
2900 let source_b = r#"
2901fn standalone_b() -> i32 {
2902 2
2903}
2904"#;
2905
2906 let path_a = create_test_file(&temp, "a.rs", source_a);
2907 let path_b = create_test_file(&temp, "b.rs", source_b);
2908
2909 let args = CouplingArgs {
2910 path_a: path_a.clone(),
2911 path_b: Some(path_b.clone()),
2912 timeout: 30,
2913 project_root: None,
2914 max_pairs: 20,
2915 top: 0,
2916 cycles_only: false,
2917 lang: None,
2918 include_tests: false,
2919 };
2920
2921 let result = run(args, OutputFormat::Json);
2922 assert!(
2923 result.is_ok(),
2924 "coupling should work for Rust files: {:?}",
2925 result.err()
2926 );
2927 }
2928
2929 #[test]
2930 fn test_unsupported_extension_returns_error() {
2931 let temp = TempDir::new().unwrap();
2932 let path = create_test_file(&temp, "data.xyz", "some content");
2933 let result = extract_module_info(&path, "some content");
2934 assert!(
2935 result.is_err(),
2936 "unsupported file extension should return error"
2937 );
2938 }
2939
2940 #[test]
2945 fn test_coupling_args_pair_mode_backward_compat() {
2946 let args = CouplingArgs {
2948 path_a: PathBuf::from("src/a.py"),
2949 path_b: Some(PathBuf::from("src/b.py")),
2950 timeout: 30,
2951 project_root: None,
2952 max_pairs: 20,
2953 top: 0,
2954 cycles_only: false,
2955 lang: None,
2956 include_tests: false,
2957 };
2958 assert!(args.path_b.is_some());
2959 }
2960
2961 #[test]
2962 fn test_coupling_args_project_wide_mode() {
2963 let args = CouplingArgs {
2965 path_a: PathBuf::from("src/"),
2966 path_b: None,
2967 timeout: 30,
2968 project_root: None,
2969 max_pairs: 20,
2970 top: 0,
2971 cycles_only: false,
2972 lang: None,
2973 include_tests: false,
2974 };
2975 assert!(args.path_b.is_none());
2976 }
2977
2978 #[test]
2979 fn test_coupling_args_max_pairs_default() {
2980 let args = CouplingArgs {
2981 path_a: PathBuf::from("src/"),
2982 path_b: None,
2983 timeout: 30,
2984 project_root: None,
2985 max_pairs: 20,
2986 top: 0,
2987 cycles_only: false,
2988 lang: None,
2989 include_tests: false,
2990 };
2991 assert_eq!(args.max_pairs, 20);
2992 }
2993
2994 #[test]
2995 fn test_coupling_args_max_pairs_custom() {
2996 let args = CouplingArgs {
2997 path_a: PathBuf::from("src/"),
2998 path_b: None,
2999 timeout: 30,
3000 project_root: None,
3001 max_pairs: 5,
3002 top: 0,
3003 cycles_only: false,
3004 lang: None,
3005 include_tests: false,
3006 };
3007 assert_eq!(args.max_pairs, 5);
3008 }
3009
3010 #[test]
3011 fn test_run_project_wide_mode() {
3012 let temp = TempDir::new().unwrap();
3013
3014 let source_a = r#"
3016from b import helper
3017
3018def caller():
3019 return helper()
3020"#;
3021 let source_b = r#"
3022def helper():
3023 return 42
3024"#;
3025 let source_c = r#"
3026def standalone():
3027 return 99
3028"#;
3029
3030 create_test_file(&temp, "a.py", source_a);
3031 create_test_file(&temp, "b.py", source_b);
3032 create_test_file(&temp, "c.py", source_c);
3033
3034 let args = CouplingArgs {
3036 path_a: temp.path().to_path_buf(),
3037 path_b: None,
3038 timeout: 30,
3039 project_root: None,
3040 max_pairs: 20,
3041 top: 0,
3042 cycles_only: false,
3043 lang: None,
3044 include_tests: false,
3045 };
3046
3047 let result = run(args, OutputFormat::Json);
3048 assert!(
3049 result.is_ok(),
3050 "project-wide coupling should succeed: {:?}",
3051 result.err()
3052 );
3053 }
3054
3055 #[test]
3056 fn test_run_pair_mode_still_works() {
3057 let temp = TempDir::new().unwrap();
3059
3060 let source_a = r#"
3061from b import helper
3062
3063def caller():
3064 return helper()
3065"#;
3066 let source_b = r#"
3067def helper():
3068 return 42
3069"#;
3070
3071 let path_a = create_test_file(&temp, "a.py", source_a);
3072 let path_b = create_test_file(&temp, "b.py", source_b);
3073
3074 let args = CouplingArgs {
3075 path_a: path_a.clone(),
3076 path_b: Some(path_b.clone()),
3077 timeout: 30,
3078 project_root: None,
3079 max_pairs: 20,
3080 top: 0,
3081 cycles_only: false,
3082 lang: None,
3083 include_tests: false,
3084 };
3085
3086 let result = run(args, OutputFormat::Json);
3087 assert!(
3088 result.is_ok(),
3089 "pair mode should still work: {:?}",
3090 result.err()
3091 );
3092 }
3093
3094 #[test]
3095 fn test_format_coupling_project_text_basic() {
3096 use tldr_core::quality::coupling::{
3097 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
3098 ModuleCoupling as CoreModuleCoupling,
3099 };
3100
3101 let report = CoreCouplingReport {
3102 modules_analyzed: 10,
3103 pairs_analyzed: 45,
3104 total_cross_file_pairs: 8,
3105 avg_coupling_score: Some(0.25),
3106 tight_coupling_count: 2,
3107 top_pairs: vec![
3108 CoreModuleCoupling {
3109 source: PathBuf::from("src/services/auth.rs"),
3110 target: PathBuf::from("src/db/users.rs"),
3111 import_count: 8,
3112 call_count: 12,
3113 calls_source_to_target: vec![],
3114 calls_target_to_source: vec![],
3115 shared_imports: vec![],
3116 score: 0.72,
3117 verdict: CoreVerdict::Tight,
3118 },
3119 CoreModuleCoupling {
3120 source: PathBuf::from("src/api/routes.rs"),
3121 target: PathBuf::from("src/services/auth.rs"),
3122 import_count: 5,
3123 call_count: 7,
3124 calls_source_to_target: vec![],
3125 calls_target_to_source: vec![],
3126 shared_imports: vec![],
3127 score: 0.55,
3128 verdict: CoreVerdict::Moderate,
3129 },
3130 CoreModuleCoupling {
3131 source: PathBuf::from("src/handlers/web.rs"),
3132 target: PathBuf::from("src/api/routes.rs"),
3133 import_count: 3,
3134 call_count: 5,
3135 calls_source_to_target: vec![],
3136 calls_target_to_source: vec![],
3137 shared_imports: vec![],
3138 score: 0.15,
3139 verdict: CoreVerdict::Loose,
3140 },
3141 ],
3142 truncated: None,
3143 total_pairs: None,
3144 shown_pairs: None,
3145 };
3146
3147 let text = format_coupling_project_text(&report);
3148
3149 assert!(
3151 text.contains("project-wide"),
3152 "should contain 'project-wide': {}",
3153 text
3154 );
3155 assert!(
3157 text.contains("Score"),
3158 "should contain Score header: {}",
3159 text
3160 );
3161 assert!(
3162 text.contains("Calls"),
3163 "should contain Calls header: {}",
3164 text
3165 );
3166 assert!(
3167 text.contains("Imports"),
3168 "should contain Imports header: {}",
3169 text
3170 );
3171 assert!(
3172 text.contains("Verdict"),
3173 "should contain Verdict header: {}",
3174 text
3175 );
3176 assert!(
3178 text.contains("0.72"),
3179 "should contain tight score: {}",
3180 text
3181 );
3182 assert!(
3183 text.contains("0.55"),
3184 "should contain moderate score: {}",
3185 text
3186 );
3187 assert!(
3188 text.contains("0.15"),
3189 "should contain loose score: {}",
3190 text
3191 );
3192 assert!(
3194 text.contains("tight"),
3195 "should contain tight verdict: {}",
3196 text
3197 );
3198 assert!(
3199 text.contains("moderate"),
3200 "should contain moderate verdict: {}",
3201 text
3202 );
3203 assert!(
3204 text.contains("loose"),
3205 "should contain loose verdict: {}",
3206 text
3207 );
3208 assert!(
3210 text.contains("10 modules"),
3211 "should contain module count: {}",
3212 text
3213 );
3214 assert!(
3215 text.contains("45 pairs"),
3216 "should contain pair count: {}",
3217 text
3218 );
3219 assert!(
3220 text.contains("2 tight"),
3221 "should contain tight count: {}",
3222 text
3223 );
3224 }
3225
3226 #[test]
3227 fn test_format_coupling_project_text_empty() {
3228 use tldr_core::quality::coupling::CouplingReport as CoreCouplingReport;
3229
3230 let report = CoreCouplingReport::default();
3231
3232 let text = format_coupling_project_text(&report);
3233
3234 assert!(
3235 text.contains("project-wide"),
3236 "should contain 'project-wide': {}",
3237 text
3238 );
3239 assert!(
3240 text.contains("0 modules"),
3241 "should contain zero modules: {}",
3242 text
3243 );
3244 }
3245
3246 #[test]
3251 fn test_format_martin_text_basic() {
3252 use tldr_core::quality::coupling::{
3253 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3254 };
3255
3256 let report = MartinMetricsReport {
3257 schema_version: "1.0".to_string(),
3258 modules_analyzed: 2,
3259 metrics: vec![
3260 MartinModuleMetrics {
3261 module: PathBuf::from("src/api.py"),
3262 ca: 0,
3263 ce: 3,
3264 instability: 1.0,
3265 in_cycle: false,
3266 },
3267 MartinModuleMetrics {
3268 module: PathBuf::from("src/db.py"),
3269 ca: 2,
3270 ce: 0,
3271 instability: 0.0,
3272 in_cycle: false,
3273 },
3274 ],
3275 cycles: vec![],
3276 summary: MartinSummary {
3277 avg_instability: 0.5,
3278 total_cycles: 0,
3279 most_stable: Some(PathBuf::from("src/db.py")),
3280 most_unstable: Some(PathBuf::from("src/api.py")),
3281 },
3282 };
3283
3284 let text = format_martin_text(&report);
3285 assert!(
3286 text.contains("Module"),
3287 "should contain Module header: {}",
3288 text
3289 );
3290 assert!(text.contains("Ca"), "should contain Ca header: {}", text);
3291 assert!(text.contains("Ce"), "should contain Ce header: {}", text);
3292 assert!(
3293 text.contains("Cycle?"),
3294 "should contain Cycle? header: {}",
3295 text
3296 );
3297 }
3298
3299 #[test]
3300 fn test_format_martin_text_empty() {
3301 use tldr_core::quality::coupling::MartinMetricsReport;
3302
3303 let report = MartinMetricsReport::default();
3304 let text = format_martin_text(&report);
3305 assert!(
3306 text.contains("No modules found"),
3307 "empty report should say 'No modules found': {}",
3308 text
3309 );
3310 }
3311
3312 #[test]
3313 fn test_format_martin_text_with_cycles() {
3314 use tldr_core::analysis::deps::DepCycle;
3315 use tldr_core::quality::coupling::{
3316 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3317 };
3318
3319 let cycle = DepCycle::new(vec![PathBuf::from("a.py"), PathBuf::from("b.py")]);
3320 let report = MartinMetricsReport {
3321 schema_version: "1.0".to_string(),
3322 modules_analyzed: 2,
3323 metrics: vec![
3324 MartinModuleMetrics {
3325 module: PathBuf::from("a.py"),
3326 ca: 1,
3327 ce: 1,
3328 instability: 0.5,
3329 in_cycle: true,
3330 },
3331 MartinModuleMetrics {
3332 module: PathBuf::from("b.py"),
3333 ca: 1,
3334 ce: 1,
3335 instability: 0.5,
3336 in_cycle: true,
3337 },
3338 ],
3339 cycles: vec![cycle],
3340 summary: MartinSummary {
3341 avg_instability: 0.5,
3342 total_cycles: 1,
3343 most_stable: Some(PathBuf::from("a.py")),
3344 most_unstable: Some(PathBuf::from("a.py")),
3345 },
3346 };
3347
3348 let text = format_martin_text(&report);
3349 assert!(
3350 text.contains("Cycles:"),
3351 "should contain 'Cycles:' section: {}",
3352 text
3353 );
3354 assert!(
3355 text.contains("->"),
3356 "should contain '->' in cycle display: {}",
3357 text
3358 );
3359 }
3360
3361 #[test]
3362 fn test_format_martin_text_no_cycles() {
3363 use tldr_core::quality::coupling::{
3364 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3365 };
3366
3367 let report = MartinMetricsReport {
3368 schema_version: "1.0".to_string(),
3369 modules_analyzed: 1,
3370 metrics: vec![MartinModuleMetrics {
3371 module: PathBuf::from("a.py"),
3372 ca: 0,
3373 ce: 0,
3374 instability: 0.0,
3375 in_cycle: false,
3376 }],
3377 cycles: vec![],
3378 summary: MartinSummary {
3379 avg_instability: 0.0,
3380 total_cycles: 0,
3381 most_stable: Some(PathBuf::from("a.py")),
3382 most_unstable: Some(PathBuf::from("a.py")),
3383 },
3384 };
3385
3386 let text = format_martin_text(&report);
3387 assert!(
3388 !text.contains("Cycles:"),
3389 "should NOT contain 'Cycles:' section when no cycles: {}",
3390 text
3391 );
3392 }
3393
3394 #[test]
3395 fn test_format_martin_text_summary_line() {
3396 use tldr_core::quality::coupling::{
3397 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3398 };
3399
3400 let report = MartinMetricsReport {
3401 schema_version: "1.0".to_string(),
3402 modules_analyzed: 3,
3403 metrics: vec![MartinModuleMetrics {
3404 module: PathBuf::from("a.py"),
3405 ca: 0,
3406 ce: 1,
3407 instability: 1.0,
3408 in_cycle: false,
3409 }],
3410 cycles: vec![],
3411 summary: MartinSummary {
3412 avg_instability: 0.5,
3413 total_cycles: 0,
3414 most_stable: Some(PathBuf::from("c.py")),
3415 most_unstable: Some(PathBuf::from("a.py")),
3416 },
3417 };
3418
3419 let text = format_martin_text(&report);
3420 assert!(
3421 text.contains("modules"),
3422 "should contain 'modules' in summary: {}",
3423 text
3424 );
3425 assert!(
3426 text.contains("avg instability"),
3427 "should contain 'avg instability' in summary: {}",
3428 text
3429 );
3430 }
3431
3432 #[test]
3433 fn test_format_coupling_project_text_path_stripping() {
3434 use tldr_core::quality::coupling::{
3435 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
3436 ModuleCoupling as CoreModuleCoupling,
3437 };
3438
3439 let report = CoreCouplingReport {
3440 modules_analyzed: 2,
3441 pairs_analyzed: 1,
3442 total_cross_file_pairs: 1,
3443 avg_coupling_score: Some(0.50),
3444 tight_coupling_count: 0,
3445 top_pairs: vec![CoreModuleCoupling {
3446 source: PathBuf::from("/home/user/project/src/auth.rs"),
3447 target: PathBuf::from("/home/user/project/src/db.rs"),
3448 import_count: 3,
3449 call_count: 4,
3450 calls_source_to_target: vec![],
3451 calls_target_to_source: vec![],
3452 shared_imports: vec![],
3453 score: 0.50,
3454 verdict: CoreVerdict::Moderate,
3455 }],
3456 truncated: None,
3457 total_pairs: None,
3458 shown_pairs: None,
3459 };
3460
3461 let text = format_coupling_project_text(&report);
3462
3463 assert!(
3465 text.contains("auth.rs"),
3466 "should show relative path auth.rs: {}",
3467 text
3468 );
3469 assert!(
3470 text.contains("db.rs"),
3471 "should show relative path db.rs: {}",
3472 text
3473 );
3474 assert!(
3476 !text.contains("/home/user/project/src/auth.rs"),
3477 "should strip common prefix from paths: {}",
3478 text
3479 );
3480 }
3481
3482 #[test]
3487 fn test_coupling_args_top_flag() {
3488 let args = CouplingArgs {
3490 path_a: PathBuf::from("src/"),
3491 path_b: None,
3492 timeout: 30,
3493 project_root: None,
3494 max_pairs: 20,
3495 top: 5,
3496 cycles_only: false,
3497 lang: None,
3498 include_tests: false,
3499 };
3500 assert_eq!(args.top, 5);
3501 }
3502
3503 #[test]
3504 fn test_coupling_args_cycles_only_flag() {
3505 let args = CouplingArgs {
3507 path_a: PathBuf::from("src/"),
3508 path_b: None,
3509 timeout: 30,
3510 project_root: None,
3511 max_pairs: 20,
3512 top: 0,
3513 cycles_only: true,
3514 lang: None,
3515 include_tests: false,
3516 };
3517 assert!(args.cycles_only);
3518 }
3519
3520 #[test]
3521 fn test_coupling_args_defaults() {
3522 let args = CouplingArgs {
3524 path_a: PathBuf::from("src/"),
3525 path_b: None,
3526 timeout: 30,
3527 project_root: None,
3528 max_pairs: 20,
3529 top: 0,
3530 cycles_only: false,
3531 lang: None,
3532 include_tests: false,
3533 };
3534 assert_eq!(args.top, 0);
3535 assert!(!args.cycles_only);
3536 }
3537
3538 #[test]
3539 fn test_project_mode_produces_martin_output() {
3540 let temp = TempDir::new().unwrap();
3542
3543 create_test_file(
3544 &temp,
3545 "a.py",
3546 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3547 );
3548 create_test_file(
3549 &temp,
3550 "b.py",
3551 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3552 );
3553 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3554
3555 let args = CouplingArgs {
3556 path_a: temp.path().to_path_buf(),
3557 path_b: None,
3558 timeout: 30,
3559 project_root: None,
3560 max_pairs: 20,
3561 top: 0,
3562 cycles_only: false,
3563 lang: None,
3564 include_tests: false,
3565 };
3566
3567 let result = run(args, OutputFormat::Text);
3569 assert!(
3570 result.is_ok(),
3571 "project mode should succeed: {:?}",
3572 result.err()
3573 );
3574 }
3577
3578 #[test]
3579 fn test_project_mode_json_has_martin_fields() {
3580 use serde_json::Value;
3581
3582 let temp = TempDir::new().unwrap();
3583
3584 create_test_file(
3585 &temp,
3586 "a.py",
3587 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3588 );
3589 create_test_file(
3590 &temp,
3591 "b.py",
3592 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3593 );
3594 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3595
3596 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3598 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3599
3600 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3601 let martin_report = compute_martin_metrics_from_deps(
3602 &deps_report,
3603 &MartinOptions {
3604 top: 0,
3605 cycles_only: false,
3606 },
3607 );
3608
3609 let json = serde_json::to_string_pretty(&martin_report).unwrap();
3610 let parsed: Value = serde_json::from_str(&json).unwrap();
3611
3612 assert!(
3613 parsed.get("modules_analyzed").is_some(),
3614 "JSON should have 'modules_analyzed': {}",
3615 json
3616 );
3617 assert!(
3618 parsed.get("metrics").is_some(),
3619 "JSON should have 'metrics': {}",
3620 json
3621 );
3622 assert!(
3623 parsed.get("summary").is_some(),
3624 "JSON should have 'summary': {}",
3625 json
3626 );
3627 }
3628
3629 #[test]
3630 fn test_project_mode_cycles_only_filter() {
3631 let temp = TempDir::new().unwrap();
3633
3634 create_test_file(
3635 &temp,
3636 "a.py",
3637 "from b import func_b\n\ndef func_a():\n return func_b()\n",
3638 );
3639 create_test_file(
3640 &temp,
3641 "b.py",
3642 "from a import func_a\n\ndef func_b():\n return func_a()\n",
3643 );
3644 create_test_file(
3645 &temp,
3646 "c.py",
3647 "from d import func_d\n\ndef func_c():\n return func_d()\n",
3648 );
3649 create_test_file(&temp, "d.py", "def func_d():\n return 42\n");
3650
3651 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3652 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3653
3654 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3655 let martin_report = compute_martin_metrics_from_deps(
3656 &deps_report,
3657 &MartinOptions {
3658 top: 0,
3659 cycles_only: true,
3660 },
3661 );
3662
3663 for m in &martin_report.metrics {
3665 assert!(
3666 m.in_cycle,
3667 "cycles_only filter should only include cycle modules, got: {:?}",
3668 m.module
3669 );
3670 }
3671 }
3672
3673 #[test]
3674 fn test_project_mode_top_n_limits() {
3675 let temp = TempDir::new().unwrap();
3677
3678 create_test_file(
3679 &temp,
3680 "a.py",
3681 "from b import fb\n\ndef fa():\n return fb()\n",
3682 );
3683 create_test_file(
3684 &temp,
3685 "b.py",
3686 "from c import fc\n\ndef fb():\n return fc()\n",
3687 );
3688 create_test_file(
3689 &temp,
3690 "c.py",
3691 "from d import fd\n\ndef fc():\n return fd()\n",
3692 );
3693 create_test_file(
3694 &temp,
3695 "d.py",
3696 "from e import fe\n\ndef fd():\n return fe()\n",
3697 );
3698 create_test_file(&temp, "e.py", "def fe():\n return 42\n");
3699
3700 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3701 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3702
3703 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3704 let martin_report = compute_martin_metrics_from_deps(
3705 &deps_report,
3706 &MartinOptions {
3707 top: 2,
3708 cycles_only: false,
3709 },
3710 );
3711
3712 assert!(
3713 martin_report.metrics.len() <= 2,
3714 "top 2 should limit metrics to at most 2, got {}",
3715 martin_report.metrics.len()
3716 );
3717 assert!(
3719 martin_report.modules_analyzed >= 3,
3720 "modules_analyzed should reflect total (not filtered), got {}",
3721 martin_report.modules_analyzed
3722 );
3723 }
3724
3725 #[test]
3726 fn test_pair_mode_unchanged() {
3727 let temp = TempDir::new().unwrap();
3729
3730 let path_a = create_test_file(&temp, "a.py", "def standalone_a():\n return 1\n");
3731 let path_b = create_test_file(&temp, "b.py", "def standalone_b():\n return 2\n");
3732
3733 let args = CouplingArgs {
3734 path_a: path_a.clone(),
3735 path_b: Some(path_b.clone()),
3736 timeout: 30,
3737 project_root: None,
3738 max_pairs: 20,
3739 top: 3,
3740 cycles_only: true,
3741 lang: None,
3742 include_tests: false,
3743 };
3744
3745 let result = run(args, OutputFormat::Json);
3747 assert!(
3748 result.is_ok(),
3749 "pair mode with new flags should still work: {:?}",
3750 result.err()
3751 );
3752 }
3753
3754 #[test]
3755 fn test_project_mode_empty_dir() {
3756 let temp = TempDir::new().unwrap();
3759
3760 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3761 use tldr_core::quality::coupling::MartinMetricsReport;
3762
3763 let deps_result = analyze_dependencies(temp.path(), &DepsOptions::default());
3764 match deps_result {
3767 Err(_) => {
3768 let empty_report = MartinMetricsReport::default();
3769 let text = format_martin_text(&empty_report);
3770 assert!(
3771 text.contains("No modules found"),
3772 "empty report should say 'No modules found': {}",
3773 text
3774 );
3775 }
3776 Ok(deps_report) => {
3777 use tldr_core::quality::coupling::{
3779 compute_martin_metrics_from_deps, MartinOptions,
3780 };
3781 let martin_report = compute_martin_metrics_from_deps(
3782 &deps_report,
3783 &MartinOptions {
3784 top: 0,
3785 cycles_only: false,
3786 },
3787 );
3788 assert_eq!(
3789 martin_report.modules_analyzed, 0,
3790 "empty dir should have 0 modules"
3791 );
3792 let text = format_martin_text(&martin_report);
3793 assert!(
3794 text.contains("No modules found"),
3795 "empty dir text should say 'No modules found': {}",
3796 text
3797 );
3798 }
3799 }
3800 }
3801
3802 #[test]
3803 fn test_project_mode_single_file() {
3804 let temp = TempDir::new().unwrap();
3805
3806 create_test_file(&temp, "only.py", "def lonely():\n return 1\n");
3807
3808 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3809 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3810
3811 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3812 let martin_report = compute_martin_metrics_from_deps(
3813 &deps_report,
3814 &MartinOptions {
3815 top: 0,
3816 cycles_only: false,
3817 },
3818 );
3819
3820 assert!(
3822 martin_report.modules_analyzed >= 1,
3823 "single file should produce at least 1 module, got {}",
3824 martin_report.modules_analyzed
3825 );
3826 }
3827
3828 #[test]
3833 fn test_format_martin_json_schema() {
3834 use serde_json::Value;
3836 use tldr_core::quality::coupling::{
3837 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3838 };
3839
3840 let report = MartinMetricsReport {
3841 schema_version: "1.0".to_string(),
3842 modules_analyzed: 1,
3843 metrics: vec![MartinModuleMetrics {
3844 module: PathBuf::from("a.py"),
3845 ca: 0,
3846 ce: 0,
3847 instability: 0.0,
3848 in_cycle: false,
3849 }],
3850 cycles: vec![],
3851 summary: MartinSummary {
3852 avg_instability: 0.0,
3853 total_cycles: 0,
3854 most_stable: Some(PathBuf::from("a.py")),
3855 most_unstable: Some(PathBuf::from("a.py")),
3856 },
3857 };
3858
3859 let json_str = serde_json::to_string_pretty(&report).unwrap();
3860 let parsed: Value = serde_json::from_str(&json_str).unwrap();
3861
3862 assert_eq!(
3863 parsed["schema_version"].as_str(),
3864 Some("1.0"),
3865 "JSON should contain schema_version=1.0, got: {}",
3866 json_str
3867 );
3868 }
3869
3870 #[test]
3871 fn test_project_mode_top_and_cycles_combined() {
3872 let temp = TempDir::new().unwrap();
3874
3875 create_test_file(
3878 &temp,
3879 "a.py",
3880 "from b import fb\n\ndef fa():\n return fb()\n",
3881 );
3882 create_test_file(
3883 &temp,
3884 "b.py",
3885 "from a import fa\nfrom c import fc\n\ndef fb():\n return fa() + fc()\n",
3886 );
3887 create_test_file(
3888 &temp,
3889 "c.py",
3890 "from b import fb\n\ndef fc():\n return fb()\n",
3891 );
3892 create_test_file(&temp, "d.py", "def fd():\n return 42\n");
3893
3894 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3895 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3896
3897 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3898 let martin_report = compute_martin_metrics_from_deps(
3899 &deps_report,
3900 &MartinOptions {
3901 top: 2,
3902 cycles_only: true,
3903 },
3904 );
3905
3906 assert!(
3908 martin_report.metrics.len() <= 2,
3909 "top 2 + cycles_only should limit to at most 2 modules, got {}",
3910 martin_report.metrics.len()
3911 );
3912 for m in &martin_report.metrics {
3913 assert!(
3914 m.in_cycle,
3915 "all returned modules should be in_cycle, but {:?} is not",
3916 m.module
3917 );
3918 }
3919 }
3920
3921 #[test]
3922 fn test_coupling_args_lang_flag() {
3923 let args = CouplingArgs {
3925 path_a: PathBuf::from("src/a.ts"),
3926 path_b: Some(PathBuf::from("src/b.ts")),
3927 timeout: 30,
3928 project_root: None,
3929 max_pairs: 20,
3930 top: 0,
3931 cycles_only: false,
3932 lang: Some(TldrLanguage::TypeScript),
3933 include_tests: false,
3934 };
3935 assert_eq!(args.lang, Some(TldrLanguage::TypeScript));
3936
3937 let args_auto = CouplingArgs {
3939 path_a: PathBuf::from("src/a.py"),
3940 path_b: None,
3941 timeout: 30,
3942 project_root: None,
3943 max_pairs: 20,
3944 top: 0,
3945 cycles_only: false,
3946 lang: None,
3947 include_tests: false,
3948 };
3949 assert_eq!(args_auto.lang, None);
3950 }
3951}