Skip to main content

tldr_cli/commands/patterns/
coupling.rs

1//! Coupling command - Cross-module coupling analysis
2//!
3//! Analyzes coupling between two source modules by tracking cross-module
4//! function calls. Computes a coupling score (0.0-1.0) and provides a verdict.
5//!
6//! Supports all languages with tree-sitter grammars: Python, Go, Rust,
7//! TypeScript, JavaScript, Java, C, C++, Ruby, C#, PHP, Scala, Elixir,
8//! Lua, Luau, and OCaml.
9//!
10//! # Example Usage
11//!
12//! ```bash
13//! tldr coupling src/auth.py src/user.py
14//! tldr coupling src/gin.go src/context.go --format text
15//! tldr coupling src/lib.rs src/utils.rs --timeout 30
16//! ```
17//!
18//! # TIGER Mitigations
19//!
20//! - **E02**: `--timeout` flag with default 30 seconds
21//! - **T02**: All path validation through `validation.rs`
22
23use 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// =============================================================================
48// CLI Arguments
49// =============================================================================
50
51/// Analyze coupling between source modules.
52///
53/// Two modes:
54/// - **Pair mode** (2 args): `tldr coupling file_a file_b` -- compare two files
55/// - **Project-wide mode** (1 arg): `tldr coupling directory/` -- scan all pairs
56///
57/// Measures **cross-module function call edges** (one module invoking a
58/// function defined in another) and computes a coupling score from
59/// those edges. A lower score indicates looser coupling; a higher score
60/// indicates tighter coupling that may benefit from refactoring.
61///
62/// ux-and-explain-completeness-v1 (P12.AGG12-14): this command
63/// intentionally measures *call edges*, not import-level dependencies.
64/// Two files where module A merely `import`s symbols from module B
65/// without calling them will report `total_calls = 0`. To inspect
66/// import-level dependencies, use `tldr deps` or `tldr imports`. The
67/// distinction matters because a Python file commonly imports many
68/// symbols (e.g. `from flask import Flask, request, g, ...`) but
69/// invokes only a subset at the call-graph level — coupling tracks the
70/// invocation surface, not the import surface.
71///
72/// Supports: Python, Go, Rust, TypeScript, JavaScript, Java, C, C++,
73/// Ruby, C#, PHP, Scala, Elixir, Lua, Luau, OCaml.
74#[derive(Debug, Clone, Args)]
75pub struct CouplingArgs {
76    /// First source module (pair mode) or directory to scan (project-wide mode)
77    pub path_a: PathBuf,
78
79    /// Second source module (pair mode). Omit for project-wide scan.
80    pub path_b: Option<PathBuf>,
81
82    /// Timeout in seconds (TIGER E02 mitigation)
83    #[arg(long, default_value = "30")]
84    pub timeout: u64,
85
86    /// Project root for path validation (optional)
87    #[arg(long)]
88    pub project_root: Option<PathBuf>,
89
90    /// Maximum number of pairs to show in project-wide mode (default: 20)
91    #[arg(long, short = 'n', default_value = "20")]
92    pub max_pairs: usize,
93
94    /// Limit output to top N modules ranked by instability (project-wide mode only). 0 = show all.
95    #[arg(long, default_value = "0")]
96    pub top: usize,
97
98    /// Only show modules involved in dependency cycles (project-wide mode only)
99    #[arg(long)]
100    pub cycles_only: bool,
101
102    /// Include test files in analysis (excluded by default)
103    #[arg(long)]
104    pub include_tests: bool,
105
106    /// Language filter (auto-detected if omitted)
107    #[arg(long, short = 'l')]
108    pub lang: Option<TldrLanguage>,
109}
110
111// =============================================================================
112// Module Information
113// =============================================================================
114
115/// Information extracted from a source module for coupling analysis.
116#[derive(Debug, Clone)]
117pub struct ModuleInfo {
118    /// Path to the module
119    pub path: PathBuf,
120    /// Names defined at module level (functions, classes)
121    pub defined_names: HashSet<String>,
122    /// Imports: alias/name -> source module
123    pub imports: HashMap<String, String>,
124    /// Call sites: (caller_func, callee_name, line)
125    pub calls: Vec<(String, String, u32)>,
126    /// Total function count for normalization
127    pub function_count: u32,
128    /// AGG13-6 (quality-metrics-and-schema-v1): parameter-type bindings
129    /// per enclosing function — `caller_func -> [(param_name, type_name)]`.
130    /// Lets `find_cross_calls` resolve method invocations on parameter
131    /// objects (`owner.getId()` where `owner: Owner`) by looking up the
132    /// parameter name in this map and checking whether the callee
133    /// module defines a class with the matching type name. Only
134    /// populated for object-typed languages (Java, C#, PHP, Scala) —
135    /// other languages leave the field empty and behave as before.
136    pub param_types: HashMap<String, Vec<(String, String)>>,
137    /// AGG13-6: receiver-aware call sites — `(caller_func, receiver_name,
138    /// callee_method, line)`. Populated alongside `calls` for the same
139    /// languages as `param_types`. The receiver is the identifier on
140    /// the LHS of `recv.method(...)`; it is `None` (filtered out below)
141    /// for bare `method(...)` and `Static.method(...)` calls.
142    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
159// =============================================================================
160// Language Configuration
161// =============================================================================
162
163/// Language-specific AST node kind configuration for coupling analysis.
164struct LangConfig {
165    /// Node kinds that represent function/method definitions
166    function_kinds: &'static [&'static str],
167    /// Node kinds that represent class/type definitions
168    class_kinds: &'static [&'static str],
169    /// Node kinds that represent import statements
170    import_kinds: &'static [&'static str],
171    /// Node kinds that represent function/method calls
172    call_kinds: &'static [&'static str],
173    /// Field name to get the function name (e.g. "name")
174    func_name_field: &'static str,
175    /// Whether to look for the name child by field or first identifier
176    use_name_field: bool,
177    /// Whether to recurse into class bodies for method definitions
178    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: &[], // Ruby uses require/require_relative as function calls
254            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"], // def/defp are calls in Elixir AST
292            class_kinds: &[],
293            import_kinds: &[], // import/use/require are calls in Elixir AST
294            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: &[], // Lua uses require() as a function call
306            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        // Kotlin and Swift are not yet supported by the parser pool
321        _ => 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
333/// Detect the language from a file path, returning a PatternsError if unsupported.
334fn 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
348// =============================================================================
349// Module Extraction
350// =============================================================================
351
352/// Extract module information from source code.
353///
354/// Detects language from file extension, parses the source using tree-sitter,
355/// and extracts:
356/// - Top-level function and class definitions
357/// - Import statements (language-specific)
358/// - Function call sites within function bodies
359pub 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 definitions and imports
380    extract_top_level_generic(&root, source, &mut info, &config, lang)?;
381
382    // Post-processing: for package-based languages (Go, Java, C#, PHP, etc.),
383    // when we see calls like `pkg.Func()`, we need to also register `Func` as
384    // an import so that cross-call detection works. This handles languages where
385    // you import a package/namespace and call functions through it (not by name).
386    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
400/// For package-based languages, add function names from qualified calls to the imports map.
401///
402/// When source has `import "pkg"` and calls `pkg.Func()`, the callee is extracted as "Func"
403/// but the import key is "pkg". This function adds "Func" -> "pkg" to the imports so that
404/// `find_cross_calls` can detect it.
405fn enrich_imports_from_qualified_calls(info: &mut ModuleInfo) {
406    // Collect new import entries to avoid borrowing conflicts
407    let mut new_imports: Vec<(String, String)> = Vec::new();
408
409    for (_caller, callee, _line) in &info.calls {
410        // If the callee is already in imports, no need to add
411        if info.imports.contains_key(callee) {
412            continue;
413        }
414        // Add it as an import reference (the callee name maps to itself as module)
415        // This enables cross-call detection: if the other module defines this function,
416        // it will be detected as a cross-call.
417        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
425/// Extract top-level definitions and imports from the AST root (generic, multi-language).
426fn 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
437/// Recursively extract definitions, imports, and calls from the AST.
438///
439/// `depth` controls recursion into class/module bodies.
440fn 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        // Check for function definitions
454        if config.function_kinds.contains(&kind) {
455            // Special case: Elixir's def/defp are call nodes
456            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                // AGG13-6: also collect param-type bindings + receiver-aware
470                // method calls for object-typed languages so cross-call
471                // detection can resolve `param.method()` against the
472                // callee's defined classes.
473                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        // Check for class/type definitions
495        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            // Recurse into class bodies to find methods
500            if config.recurse_into_classes && depth < 3 {
501                extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
502            }
503        }
504        // Check for import statements
505        else if config.import_kinds.contains(&kind) {
506            extract_imports_generic(&child, source, &mut info.imports, lang);
507        }
508        // Ruby: detect require/require_relative calls at top level
509        else if lang == TldrLanguage::Ruby && (kind == "call" || kind == "command") {
510            extract_ruby_require(&child, source, &mut info.imports);
511        }
512        // For languages where module body is nested (Java class_body, C# namespace, etc.)
513        else if is_body_container(kind, lang) {
514            extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
515        }
516    }
517}
518
519/// Check if a node kind is a body container that should be recursed into.
520fn 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
538/// Get the name of a function/class/type definition node (generic).
539fn get_name_generic(
540    node: &Node,
541    source: &str,
542    config: &LangConfig,
543    _lang: TldrLanguage,
544) -> Option<String> {
545    // Try field-based lookup first (most languages)
546    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            // For C/C++, the declarator may be a function_declarator wrapping an identifier
549            return Some(extract_leaf_identifier(&name_node, source));
550        }
551    }
552
553    // Fallback: find the first identifier child
554    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
564/// Extract the leaf identifier from a node that might be a complex declarator.
565///
566/// Handles C/C++ patterns like `function_declarator -> identifier`.
567fn 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    // Recurse to find the first identifier
573    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        // Recurse into function_declarator, pointer_declarator, etc.
579        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
588/// Extract Elixir def/defp function name from a call node.
589fn extract_elixir_def_name(node: &Node, source: &str) -> Option<String> {
590    // In Elixir AST, `def foo(args)` is a call where the target is "def"
591    // and the first argument contains the function name
592    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            // Next sibling should have the function name
597            if let Some(args) = child.next_sibling() {
598                return get_first_identifier(&args, source);
599            }
600        }
601    }
602    None
603}
604
605/// Get the first identifier in a subtree.
606fn 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
619// =============================================================================
620// Import Extraction (Generic)
621// =============================================================================
622
623/// Extract imports from an import node (generic, multi-language).
624fn 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        // Languages using function-call-based imports (Ruby, Lua, Elixir)
644        // are handled separately in the caller
645        _ => extract_fallback_imports(node, source, imports),
646    }
647}
648
649/// Python: import X, from X import Y
650fn 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        // First pass: find the module name
669        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        // Second pass: find all imported names
686        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
718/// Recursively extract imported names from a node subtree (Python).
719fn 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
745/// Extract name and alias from an aliased_import node (Python).
746fn 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
767/// Go: import "pkg" or import ( "pkg1"; "pkg2" )
768fn extract_go_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
769    // import_declaration can contain import_spec or import_spec_list
770    let mut stack = vec![*node];
771    while let Some(n) = stack.pop() {
772        if n.kind() == "import_spec" {
773            // import_spec has optional name (alias) and path (string literal)
774            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                // Use the last component as the key (e.g., "fmt" from "fmt", "render" from "gin/render")
781                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
801/// Rust: use crate::module::item;
802fn extract_rust_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
803    // use_declaration contains a scoped_identifier or use_wildcard
804    let text = node_text(node, source);
805    // Strip "use " prefix and ";" suffix
806    let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
807
808    // Handle use a::b::{c, d} or use a::b::c
809    if let Some(last) = trimmed.rsplit("::").next() {
810        if last.starts_with('{') {
811            // Grouped imports: use a::b::{c, d}
812            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
826/// TypeScript/JavaScript: import { x } from 'y'; import * as x from 'y';
827fn extract_ts_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
828    // Find the source string (the module path)
829    let mut module_path = String::new();
830    let mut cursor = node.walk();
831
832    // Find the source/from clause
833    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        // Fallback: look for string children
838        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    // Extract imported names
847    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                // import * as name
855                if let Some(name) = child.child_by_field_name("name") {
856                    imports.insert(node_text(&name, source), module_path.clone());
857                } else {
858                    // Fallback: get last identifier
859                    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
879/// Java: import com.example.Class;
880fn extract_java_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
881    // import_declaration has a scoped_identifier child
882    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
894/// C/C++: #include <header.h> or #include "header.h"
895fn extract_c_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
896    // preproc_include has a path child (system_lib_string or string_literal)
897    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            // Use the filename without path as key
906            let short = header.rsplit('/').next().unwrap_or(&header).to_string();
907            imports.insert(short, header);
908        }
909    }
910
911    // Fallback: if the path child is wrapped
912    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
922/// C#: using System.Collections;
923fn 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    // Also add the full path
935    if !trimmed.is_empty() {
936        imports.insert(trimmed.to_string(), trimmed.to_string());
937    }
938}
939
940/// PHP: use App\Utils\Helper;
941fn 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    // Handle grouped: use App\{A, B}
946    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
962/// Scala: import com.example.Class
963fn 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
972/// OCaml: open Module
973fn 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
981/// Fallback import extraction: just record the text.
982fn 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
989/// Ruby: detect require/require_relative calls
990fn extract_ruby_require(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
991    // In Ruby, require is a method call: require 'json' or require_relative 'helper'
992    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                // Recurse into argument list
1018                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
1040/// Collect identifiers from a subtree and add them as imports.
1041fn 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
1057// =============================================================================
1058// Call Extraction (Generic)
1059// =============================================================================
1060
1061/// Extract call sites within a function body (generic, multi-language).
1062fn 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        // Push children to stack (depth-first traversal)
1081        let mut cursor = node.walk();
1082        for child in node.children(&mut cursor) {
1083            stack.push(child);
1084        }
1085    }
1086}
1087
1088/// AGG13-6 (quality-metrics-and-schema-v1): extract `(param_name,
1089/// type_name)` pairs from a Java / C# / PHP / Scala method definition.
1090/// Returns an empty Vec when the function has no typed parameters or
1091/// when the language layout is not recognized. This lets
1092/// `find_cross_calls` map `owner.getId()` (where the enclosing method
1093/// took an `Owner owner` parameter) back to the defining class
1094/// `Owner` and count the method invocation as cross-module coupling.
1095///
1096/// Tree-sitter shapes consulted:
1097/// - Java `formal_parameter` -> `type: type_identifier`, `name: identifier`
1098/// - C# `parameter`         -> `type: predefined_type|identifier|...`, `name: identifier`
1099/// - PHP `simple_parameter` -> `type: named_type` (optional), `name: variable_name`
1100/// - Scala `parameters` ->  `parameter` -> `name: identifier`, `type: type_identifier`
1101fn 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    // Locate the parameters list
1109    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        // Filter to actual parameter nodes; tree-sitter exposes
1124        // punctuation children we don't want to recurse into.
1125        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        // Type extraction: most grammars expose a `type` field.
1138        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        // Name extraction. Java/Scala use `name` field; PHP uses
1148        // `variable_name`. Strip the PHP `$` sigil for matching
1149        // against call-site receivers.
1150        let name_node = param
1151            .child_by_field_name("name")
1152            .or_else(|| first_named_kind(&param, "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
1170/// Find the first child of `node` whose kind matches `target_kind`.
1171/// Lightweight helper for AGG13-6 PHP variable-name extraction.
1172fn 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
1182/// AGG13-6: walk a function body and collect every method invocation
1183/// of the form `receiver.method(...)`, recording the receiver
1184/// identifier text. Used together with `extract_param_types` so
1185/// `find_cross_calls` can match parameter-typed receivers against
1186/// classes defined in the other module. Bare calls (`method(...)`)
1187/// and static-class calls (`Static.method(...)`) are NOT recorded
1188/// here — those are already handled by `find_cross_calls` via the
1189/// existing `imports` lookup.
1190fn 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
1222/// Extract `(receiver_name, method_name)` from a method-call node.
1223/// Returns `None` for bare calls (no receiver) or when the receiver
1224/// is not a simple identifier (e.g. `getOwner().setName()` has a
1225/// call-expression receiver — we don't try to unify those because
1226/// we lack return-type info).
1227fn 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            // Only handle simple identifier receivers — chained calls
1236            // like `getOwner().setName()` need return-type info we
1237            // don't have, so we skip them rather than guess.
1238            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            // Strip `$` sigil to match the parameter-type table.
1262            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
1285/// Extract the callee name from a call node (generic, multi-language).
1286///
1287/// Handles:
1288/// - Simple calls: `func()` -> "func"
1289/// - Attribute/method calls: `obj.method()` -> "method"
1290/// - Selector calls (Go): `pkg.Func()` -> "Func"
1291/// - Java method invocation: `obj.method()` -> "method"
1292fn extract_callee_generic(call_node: &Node, source: &str, lang: TldrLanguage) -> Option<String> {
1293    match lang {
1294        TldrLanguage::Java => {
1295            // Java method_invocation: child_by_field_name("name") gives the method name
1296            if let Some(name) = call_node.child_by_field_name("name") {
1297                return Some(node_text(&name, source));
1298            }
1299        }
1300        TldrLanguage::Go => {
1301            // Go call_expression: function field is the callee
1302            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                        // pkg.Func() -> extract "Func"
1307                        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            // PHP function_call_expression or member_call_expression
1317            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            // C# invocation_expression: function is first child
1326            if let Some(func) = call_node.child_by_field_name("function") {
1327                return Some(extract_last_identifier(&func, source));
1328            }
1329            // Fallback
1330            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    // Generic fallback: works for Python, Rust, TypeScript, C, C++, Ruby, etc.
1346    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                // Get the method/field name (after the dot)
1354                return Some(extract_last_identifier(&child, source));
1355            }
1356            "scoped_identifier" | "qualified_identifier" => {
1357                // Rust/C++ path::func()
1358                return Some(extract_last_identifier(&child, source));
1359            }
1360            _ => {}
1361        }
1362    }
1363    None
1364}
1365
1366/// Extract the last identifier from a dotted/scoped expression.
1367fn 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
1382/// Get text content of a node.
1383fn node_text(node: &Node, source: &str) -> String {
1384    source[node.byte_range()].to_string()
1385}
1386
1387// =============================================================================
1388// Cross-Call Detection
1389// =============================================================================
1390
1391/// Find cross-module calls from caller module to callee module.
1392///
1393/// A cross-call is detected when:
1394/// 1. The caller module imports a name from the callee module
1395/// 2. The caller module calls that imported name
1396/// 3. The callee module defines that name
1397pub 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        // Check if the callee name is:
1402        // 1. Imported by the caller module
1403        // 2. Defined in the callee module
1404        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    // AGG13-6 (quality-metrics-and-schema-v1): Java/C#/PHP/Scala
1414    // parameter-typed method calls. Pre-fix `OwnerController.coupling
1415    // (Owner.java) = 0` even though OwnerController calls
1416    // `owner.getId()`, `owner.getLastName()`, `owner.setId()` 5+
1417    // times — because the receiver `owner` is a method *parameter*,
1418    // not an imported symbol, the simple `imports.contains_key`
1419    // check missed every site. Match each receiver-aware call site
1420    // against the enclosing function's parameter-type table; if the
1421    // receiver's type is a class defined in the callee module, count
1422    // the call as a cross-module dependency.
1423    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        // The receiver's static type must be a class defined in the
1436        // callee module — that's what makes this a cross-module
1437        // edge. We deliberately do NOT require the method itself to
1438        // be in `defined_names`: defined_names typically holds class
1439        // and top-level function names, not member methods, so an
1440        // exact-method check would over-filter (e.g. `getId` is a
1441        // generated bean accessor on `Owner`, not in
1442        // `defined_names`). The presence of the type is sufficient
1443        // evidence of coupling.
1444        if callee.defined_names.contains(type_name) {
1445            // Avoid double-counting the same call site if the simple
1446            // imports-based loop above already recorded it.
1447            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
1464// =============================================================================
1465// Project Call-Graph Augmentation (P17.AGG17-3)
1466// =============================================================================
1467
1468/// Augment the AST-derived cross-call counts (`a_to_b` / `b_to_a`) with
1469/// any cross-file edges visible in the project call graph between the
1470/// two specific files.
1471///
1472/// This is best-effort: if call-graph construction fails for any reason
1473/// (unsupported language, parse error, IO error), the function is a
1474/// no-op so the original AST-derived counts are preserved.
1475fn 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    // Resolve the project root as the deepest common ancestor of the
1483    // two file paths. Fall back to the parent of path_a if no common
1484    // ancestor can be derived (e.g. one of the paths is just a basename).
1485    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    // Detect language from path_a if not supplied; bail if both fail.
1493    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    // Build the project call graph rooted at the common ancestor. This
1502    // is the same routine `tldr calls` uses, so the augmentation is by
1503    // construction consistent with what `tldr calls` reports.
1504    let graph = match tldr_core::build_project_call_graph(&root, language, None, true) {
1505        Ok(g) => g,
1506        Err(_) => return,
1507    };
1508
1509    // file_a_basename / file_b_basename used to suffix-match the edge
1510    // paths against the user-supplied paths. The project call graph
1511    // emits paths relative to the project root (e.g. `app.py`,
1512    // `sansio/app.py`); the user-supplied paths are typically absolute
1513    // or relative to cwd. Suffix-matching on the trailing relative
1514    // path lets us identify the right edges without hard-coding a
1515    // canonicalisation rule.
1516    let suffix_a = relative_suffix(&canon_a, &root);
1517    let suffix_b = relative_suffix(&canon_b, &root);
1518
1519    // Avoid double-counting calls already recorded by the AST walker.
1520    // The AST walker keys cross-calls on (caller_func, callee_name,
1521    // line); the call graph emits (src_func, dst_func, src_file,
1522    // dst_file). Deduplicate by `(caller, callee, line)` triplet,
1523    // treating absent line numbers as 0.
1524    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
1570/// Find the deepest directory ancestor common to both paths. Returns
1571/// `None` if no common ancestor exists.
1572fn 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` happens to be a file (rare; both paths identical),
1587    // step up to its parent.
1588    if common.is_file() {
1589        return common.parent().map(|p| p.to_path_buf());
1590    }
1591    Some(common)
1592}
1593
1594/// Compute the path of `file` relative to `root` as a forward-slash
1595/// string suitable for suffix-matching against project call graph
1596/// `src_file` / `dst_file` strings.
1597fn 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
1607/// Check whether a project-call-graph edge path matches the requested
1608/// file. Both forms are normalised to forward-slash and *must* be
1609/// exactly equal — the call-graph is rooted at the same common-ancestor
1610/// directory we used to derive the suffixes, so any mismatch means a
1611/// different file.
1612///
1613/// Earlier prototypes used a suffix-based match
1614/// (`edge_norm.ends_with("/{}", suffix)`), but that conflated
1615/// `flask/app.py` with `flask/sansio/app.py` (every basename match
1616/// triggered) — a P17.AGG17-3 false positive that inflated the cross
1617/// call count by 35+ intra-file edges. Strict equality avoids that.
1618fn path_matches(edge_path: &str, suffix: &str) -> bool {
1619    if suffix.is_empty() {
1620        return false;
1621    }
1622    edge_path.replace('\\', "/") == suffix
1623}
1624
1625// =============================================================================
1626// Coupling Score Computation
1627// =============================================================================
1628
1629/// Compute coupling score between two modules.
1630///
1631/// The score is computed as:
1632/// `cross_calls / (total_functions * 2)`
1633///
1634/// Where:
1635/// - `cross_calls` = calls from A to B + calls from B to A
1636/// - `total_functions` = functions in A + functions in B
1637///
1638/// The score is clamped to [0.0, 1.0].
1639pub 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
1651// =============================================================================
1652// Text Formatting
1653// =============================================================================
1654
1655/// Format Martin metrics report as human-readable text.
1656///
1657/// Renders a table of per-module Ca, Ce, Instability, and cycle membership,
1658/// followed by a summary line and (if applicable) a list of detected cycles.
1659pub 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    // Compute column width for module path (min 6 for "Module", max 40)
1670    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    // Header
1679    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    // Rows
1693    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    // Summary line
1718    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    // Cycles section (only if cycles exist)
1724    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
1744/// Format coupling report as human-readable text.
1745pub 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    // A -> B calls
1761    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    // B -> A calls
1778    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
1796// =============================================================================
1797// Entry Point
1798// =============================================================================
1799
1800/// Run the coupling command.
1801///
1802/// Two modes:
1803/// - **Pair mode**: `path_b` is `Some(...)` -- compare two files (original behavior)
1804/// - **Project-wide mode**: `path_b` is `None` and `path_a` is a directory -- scan all pairs
1805///
1806/// Language is auto-detected from file extensions.
1807pub fn run(args: CouplingArgs, format: OutputFormat) -> Result<()> {
1808    // Determine mode based on arguments
1809    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            // path_a is a file but no path_b -- ambiguous
1814            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
1822/// Run pair mode: compare two specific files (original behavior).
1823fn 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    // Validate paths (TIGER T02 mitigation)
1830    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    // Check timeout after path validation
1843    if start.elapsed() > timeout {
1844        return Err(PatternsError::Timeout {
1845            timeout_secs: args.timeout,
1846        }
1847        .into());
1848    }
1849
1850    // Read source files
1851    let source_a = read_file_safe(&path_a)?;
1852    let source_b = read_file_safe(&path_b)?;
1853
1854    // Check timeout after file read
1855    if start.elapsed() > timeout {
1856        return Err(PatternsError::Timeout {
1857            timeout_secs: args.timeout,
1858        }
1859        .into());
1860    }
1861
1862    // (path-and-schema-cleanup-v3 P3.BUG-N2) For JSON emit, echo the
1863    // user-supplied paths so macOS does not rewrite `/tmp/...` to
1864    // `/private/tmp/...`. The canonical paths are still used for the
1865    // actual file reads and AST analysis above; only the emitted
1866    // `path_a` / `path_b` strings are re-derived from `args`.
1867    let user_path_a = args.path_a.display().to_string();
1868    let user_path_b = path_b_ref.display().to_string();
1869
1870    // Handle self-coupling case
1871    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    // Extract module information
1887    let info_a = extract_module_info(&path_a, &source_a)?;
1888    let info_b = extract_module_info(&path_b, &source_b)?;
1889
1890    // Check timeout after parsing
1891    if start.elapsed() > timeout {
1892        return Err(PatternsError::Timeout {
1893            timeout_secs: args.timeout,
1894        }
1895        .into());
1896    }
1897
1898    // Find cross-module calls
1899    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    // non-judgment-call-bugs-v1 (P17.AGG17-3): the AST walker above
1903    // resolves a call as cross-module only when the callee name is
1904    // both `imports.contains_key`d AND in the callee module's
1905    // `defined_names`. That misses cases where a class in module A
1906    // inherits from a class in module B and invokes inherited methods
1907    // via `super().method()` or `self.inherited_method()` — the
1908    // method name is never imported and never defined in module A,
1909    // so `find_cross_calls` returns 0 even though the project call
1910    // graph (consumed by `tldr calls`) clearly shows the cross-file
1911    // edges. Augment the AST result by consulting the project call
1912    // graph for edges between the two specific files. This restores
1913    // parity with `tldr calls` for inheritance-driven coupling
1914    // (Flask → sansio.App, OwnerController → Owner, etc.).
1915    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    // Compute coupling score
1924    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    // Build report — emit user-supplied paths (P3.BUG-N2)
1934    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
1949/// Run project-wide mode: scan a directory for all coupling pairs.
1950fn run_project_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1951    // Existing pairwise coupling analysis
1952    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    // Filter test files from pairwise by default
1956    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    // Martin metrics: compute from dependency graph
1963    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(), // no source files or unsupported language
1970    };
1971
1972    // Filter test files by default (--include-tests to keep them)
1973    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        // Recalculate summary if we filtered anything
1979        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            // Filter cycles to only include non-test modules
1999            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
2010/// Output the project-wide report with Martin metrics in the specified format.
2011fn 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            // Martin metrics first, then pairwise coupling
2019            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
2044/// Output the pair-mode report in the specified format.
2045fn 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
2062/// Format a project-wide coupling report as human-readable text.
2063///
2064/// Renders a ranked table of the highest-coupling module pairs with color coding:
2065/// - Tight (>= 0.6): red bold
2066/// - Moderate (0.3-0.6): yellow
2067/// - Loose (< 0.3): green
2068pub 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    // Compute common path prefix for relative display
2085    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    // Header
2093    output.push_str(&format!(
2094        " {:>5}  {:>5}  {:>7}  {:>10}  {}\n",
2095        "Score", "Calls", "Imports", "Verdict", "Source -> Target"
2096    ));
2097
2098    // Rows
2099    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    // Summary line
2123    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// =============================================================================
2147// Tests
2148// =============================================================================
2149
2150#[cfg(test)]
2151mod tests {
2152    use super::*;
2153    use std::fs;
2154    use tempfile::TempDir;
2155
2156    /// Create a test file in a temp directory.
2157    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    // -------------------------------------------------------------------------
2164    // compute_coupling_score Tests
2165    // -------------------------------------------------------------------------
2166
2167    #[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        // 2 calls from A to B, 5 functions in A, 5 in B
2176        // score = 2 / (10 * 2) = 2/20 = 0.1
2177        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        // 3 calls A->B, 2 calls B->A, 5 functions each
2184        // score = 5 / (10 * 2) = 5/20 = 0.25
2185        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        // Many calls, few functions -> clamped to 1.0
2198        let score = compute_coupling_score(100, 100, 1, 1);
2199        assert_eq!(score, 1.0);
2200    }
2201
2202    // -------------------------------------------------------------------------
2203    // CouplingVerdict Tests
2204    // -------------------------------------------------------------------------
2205
2206    #[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    // -------------------------------------------------------------------------
2235    // extract_module_info Tests
2236    // -------------------------------------------------------------------------
2237
2238    #[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        // Should find calls to helper, method, other_func
2294        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    // -------------------------------------------------------------------------
2305    // find_cross_calls Tests
2306    // -------------------------------------------------------------------------
2307
2308    #[test]
2309    fn test_find_cross_calls_simple() {
2310        let temp = TempDir::new().unwrap();
2311
2312        // Module A imports and calls helper from module B
2313        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        // Module B defines helper
2323        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        // Module A calls helper but doesn't import it
2342        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        // Module B defines helper
2350        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        // No cross-calls since helper wasn't imported
2360        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        // Module A imports and calls helper from B
2368        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        // Module B imports and calls func_a from A
2378        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    // -------------------------------------------------------------------------
2398    // format_coupling_text Tests
2399    // -------------------------------------------------------------------------
2400
2401    #[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    // -------------------------------------------------------------------------
2432    // Integration Tests
2433    // -------------------------------------------------------------------------
2434
2435    #[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        // Just verify it runs without error
2464        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    // =========================================================================
2503    // Multi-language Tests
2504    // =========================================================================
2505
2506    #[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        // Should detect functions
2530        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        // Should detect imports
2538        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        // Should find the cross-call from A to B
2572        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        // Should detect functions
2599        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        // Should detect imports
2607        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        // Should detect functions
2634        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        // Should detect imports
2642        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        // Should detect methods
2670        assert!(info.defined_names.contains("caller"), "missing caller");
2671        assert!(
2672            info.defined_names.contains("standalone"),
2673            "missing standalone"
2674        );
2675
2676        // Should detect imports
2677        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        // Should detect functions
2704        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        // Should detect includes as imports
2712        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        // Should detect methods
2739        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        // Should detect requires as imports
2747        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    // =========================================================================
2941    // Project-Wide Scan Mode Tests
2942    // =========================================================================
2943
2944    #[test]
2945    fn test_coupling_args_pair_mode_backward_compat() {
2946        // Pair mode: path_a and path_b both set
2947        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        // Project-wide mode: only path_a, no path_b
2964        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        // Create a small project with multiple Python files
3015        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        // Project-wide: pass directory as path_a, no path_b
3035        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        // Backward compatibility: pair mode must still work
3058        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        // Header
3150        assert!(
3151            text.contains("project-wide"),
3152            "should contain 'project-wide': {}",
3153            text
3154        );
3155        // Table header columns
3156        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        // Data rows
3177        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        // Verdict labels
3193        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        // Summary line
3209        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    // =========================================================================
3247    // Martin Metrics Text Formatter Tests
3248    // =========================================================================
3249
3250    #[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        // Should strip common prefix and show relative paths
3464        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        // Should NOT contain the full absolute path
3475        assert!(
3476            !text.contains("/home/user/project/src/auth.rs"),
3477            "should strip common prefix from paths: {}",
3478            text
3479        );
3480    }
3481
3482    // =========================================================================
3483    // Phase 3: CLI Args + Project-Mode Martin Integration Tests
3484    // =========================================================================
3485
3486    #[test]
3487    fn test_coupling_args_top_flag() {
3488        // Verify CouplingArgs can be constructed with top == 5
3489        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        // Verify CouplingArgs can be constructed with cycles_only == true
3506        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        // Verify default values: top == 0, cycles_only == false
3523        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        // Create tempdir with 3 Python files: a imports b, b imports c, c standalone
3541        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        // Run project mode and capture output
3568        let result = run(args, OutputFormat::Text);
3569        assert!(
3570            result.is_ok(),
3571            "project mode should succeed: {:?}",
3572            result.err()
3573        );
3574        // The text output goes to stdout; we verify it doesn't fail.
3575        // For deeper content check, call the internal function directly.
3576    }
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        // Call the internal functions directly to get the martin report
3597        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        // Create A->B->A cycle + C->D no-cycle
3632        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        // With cycles_only, only modules in cycles should appear in metrics
3664        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        // Create 5+ modules
3676        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        // modules_analyzed should still reflect the total count
3718        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        // Pair mode should still work with the new fields present
3728        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        // Pair mode should ignore top and cycles_only, and succeed
3746        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        // analyze_dependencies errors on a directory with no source files
3757        // (it can't auto-detect a language). Verify we handle this gracefully.
3758        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        // Empty dir → error is expected from analyze_dependencies
3765        // The empty MartinMetricsReport should format as "No modules found"
3766        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                // If analyze_dependencies somehow succeeds with 0 modules, verify that too
3778                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        // Single file should show in output (at least 1 module analyzed)
3821        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    // =========================================================================
3829    // Phase 4: Edge Case Tests
3830    // =========================================================================
3831
3832    #[test]
3833    fn test_format_martin_json_schema() {
3834        // JSON output should include schema_version field
3835        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        // --top 2 --cycles-only should show max 2 cycle-participating modules
3873        let temp = TempDir::new().unwrap();
3874
3875        // Create 4 modules: A<->B cycle, B<->C cycle, D standalone
3876        // This gives us A, B, C in cycles
3877        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        // With both filters: should show at most 2 modules, and all must be in_cycle
3907        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        // Verify CouplingArgs has a lang field of type Option<Language>
3924        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        // Also test None case (auto-detect)
3938        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}