Skip to main content

archidoc_rust/
cargo_modules.rs

1//! cargo-modules integration for import graph validation and orphan detection.
2//!
3//! Provides optional integration with the cargo-modules tool for:
4//! - Extracting actual module dependency graph from Rust code
5//! - Validating declared relationships against actual imports
6//! - Detecting orphaned modules (undocumented modules)
7//!
8//! All functionality gracefully degrades if cargo-modules is not installed.
9
10use archidoc_types::ModuleDoc;
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use std::process::Command;
14
15/// Check if cargo-modules is available on the system.
16///
17/// Returns true if `cargo modules --version` succeeds.
18pub fn check_cargo_modules_available() -> bool {
19    Command::new("cargo")
20        .args(["modules", "--version"])
21        .output()
22        .map(|output| output.status.success())
23        .unwrap_or(false)
24}
25
26/// Import graph extracted from cargo-modules.
27///
28/// Contains nodes (module paths) and edges (dependencies between modules).
29#[derive(Debug, Clone, Default)]
30pub struct ImportGraph {
31    /// Module paths that exist in the crate
32    pub nodes: HashSet<String>,
33    /// Dependencies: (from_module, to_module)
34    pub edges: Vec<(String, String)>,
35}
36
37impl ImportGraph {
38    /// Check if a dependency exists from one module to another.
39    pub fn has_dependency(&self, from: &str, to: &str) -> bool {
40        self.edges.iter().any(|(f, t)| f == from && t == to)
41    }
42
43    /// Get all dependencies of a module.
44    pub fn get_dependencies(&self, module: &str) -> Vec<String> {
45        self.edges
46            .iter()
47            .filter(|(f, _)| f == module)
48            .map(|(_, t)| t.clone())
49            .collect()
50    }
51}
52
53/// Extract the import graph by running cargo-modules and parsing DOT output.
54///
55/// Returns Ok(graph) if cargo-modules succeeds, Err(message) otherwise.
56pub fn extract_import_graph(root: &Path) -> Result<ImportGraph, String> {
57    if !check_cargo_modules_available() {
58        return Err("cargo-modules is not installed".to_string());
59    }
60
61    let output = Command::new("cargo")
62        .args(["modules", "dependencies", "--layout", "dot"])
63        .current_dir(root)
64        .output()
65        .map_err(|e| format!("Failed to run cargo modules: {}", e))?;
66
67    if !output.status.success() {
68        let stderr = String::from_utf8_lossy(&output.stderr);
69        return Err(format!("cargo modules failed: {}", stderr));
70    }
71
72    let stdout = String::from_utf8_lossy(&output.stdout);
73    parse_dot_output(&stdout)
74}
75
76/// Parse DOT format output from cargo-modules.
77///
78/// Expected format:
79/// ```dot
80/// digraph {
81///   "crate_name" -> "module_a"
82///   "module_a" -> "module_b"
83///   ...
84/// }
85/// ```
86fn parse_dot_output(dot: &str) -> Result<ImportGraph, String> {
87    let mut graph = ImportGraph::default();
88
89    for line in dot.lines() {
90        let trimmed = line.trim();
91
92        // Match edge: "from" -> "to"
93        if let Some(arrow_pos) = trimmed.find("->") {
94            let from_part = trimmed[..arrow_pos].trim();
95            let to_part = trimmed[arrow_pos + 2..].trim();
96
97            // Extract quoted strings
98            let from = extract_quoted(from_part)?;
99            let to = extract_quoted(to_part)?;
100
101            // Convert crate::path to dot notation
102            let from_module = crate_path_to_module(&from);
103            let to_module = crate_path_to_module(&to);
104
105            graph.nodes.insert(from_module.clone());
106            graph.nodes.insert(to_module.clone());
107            graph.edges.push((from_module, to_module));
108        }
109    }
110
111    Ok(graph)
112}
113
114/// Extract content from quoted string.
115fn extract_quoted(s: &str) -> Result<String, String> {
116    if let Some(start) = s.find('"') {
117        if let Some(end) = s[start + 1..].find('"') {
118            return Ok(s[start + 1..start + 1 + end].to_string());
119        }
120    }
121    Err(format!("Failed to extract quoted string from: {}", s))
122}
123
124/// Convert cargo-modules path format to dot notation.
125///
126/// Examples:
127/// - "crate_name" -> "crate_name"
128/// - "crate_name::module_a" -> "module_a"
129/// - "crate_name::module_a::module_b" -> "module_a.module_b"
130fn crate_path_to_module(path: &str) -> String {
131    let parts: Vec<&str> = path.split("::").collect();
132
133    if parts.len() <= 1 {
134        // Top-level or crate root
135        return parts[0].to_string();
136    }
137
138    // Skip crate name, join rest with dots
139    parts[1..].join(".")
140}
141
142/// Warning about a relationship that doesn't match the import graph.
143#[derive(Debug, Clone)]
144pub struct RelationshipWarning {
145    pub module: String,
146    pub target: String,
147    pub kind: WarningKind,
148}
149
150#[derive(Debug, Clone)]
151pub enum WarningKind {
152    /// Declared relationship but no actual import found
153    NoImport,
154    /// Import exists but no relationship declared
155    Undeclared,
156}
157
158/// Validate declared relationships against the actual import graph.
159///
160/// Returns warnings for mismatches between documentation and code.
161pub fn validate_relationships(
162    docs: &[ModuleDoc],
163    graph: &ImportGraph,
164) -> Vec<RelationshipWarning> {
165    let mut warnings = Vec::new();
166
167    // Build a map of declared relationships
168    let mut declared: HashMap<String, HashSet<String>> = HashMap::new();
169    for doc in docs {
170        let targets: HashSet<String> = doc
171            .relationships
172            .iter()
173            .map(|r| r.target.clone())
174            .collect();
175        declared.insert(doc.module_path.clone(), targets);
176    }
177
178    // Check each documented module
179    for doc in docs {
180        let module = &doc.module_path;
181        let actual_deps: HashSet<String> = graph
182            .get_dependencies(module)
183            .into_iter()
184            .collect();
185
186        let declared_deps = declared.get(module).cloned().unwrap_or_default();
187
188        // Check for declared but not imported
189        for target in &declared_deps {
190            if !actual_deps.contains(target) {
191                warnings.push(RelationshipWarning {
192                    module: module.clone(),
193                    target: target.clone(),
194                    kind: WarningKind::NoImport,
195                });
196            }
197        }
198
199        // Check for imported but not declared
200        for target in &actual_deps {
201            if !declared_deps.contains(target) {
202                warnings.push(RelationshipWarning {
203                    module: module.clone(),
204                    target: target.clone(),
205                    kind: WarningKind::Undeclared,
206                });
207            }
208        }
209    }
210
211    warnings
212}
213
214/// Detect orphaned modules (exist in code but not documented).
215///
216/// Returns module paths that exist in the import graph but have no documentation.
217pub fn detect_orphans(docs: &[ModuleDoc], graph: &ImportGraph) -> Vec<String> {
218    let documented: HashSet<String> = docs
219        .iter()
220        .map(|d| d.module_path.clone())
221        .collect();
222
223    graph
224        .nodes
225        .iter()
226        .filter(|node| !documented.contains(*node))
227        .cloned()
228        .collect()
229}
230
231/// Detect orphaned modules by running cargo-modules orphans command.
232///
233/// Returns list of module paths that are orphaned (not imported by anything).
234pub fn detect_orphans_cmd(root: &Path) -> Result<Vec<String>, String> {
235    if !check_cargo_modules_available() {
236        return Err("cargo-modules is not installed".to_string());
237    }
238
239    let output = Command::new("cargo")
240        .args(["modules", "orphans"])
241        .current_dir(root)
242        .output()
243        .map_err(|e| format!("Failed to run cargo modules: {}", e))?;
244
245    if !output.status.success() {
246        let stderr = String::from_utf8_lossy(&output.stderr);
247        return Err(format!("cargo modules failed: {}", stderr));
248    }
249
250    let stdout = String::from_utf8_lossy(&output.stdout);
251
252    // Parse output - typically one module per line
253    let orphans: Vec<String> = stdout
254        .lines()
255        .map(|line| line.trim())
256        .filter(|line| !line.is_empty())
257        .map(|line| crate_path_to_module(line))
258        .collect();
259
260    Ok(orphans)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_crate_path_to_module() {
269        assert_eq!(crate_path_to_module("my_crate"), "my_crate");
270        assert_eq!(crate_path_to_module("my_crate::core"), "core");
271        assert_eq!(
272            crate_path_to_module("my_crate::core::types"),
273            "core.types"
274        );
275    }
276
277    #[test]
278    fn test_extract_quoted() {
279        assert_eq!(
280            extract_quoted(r#""my_crate::core""#).unwrap(),
281            "my_crate::core"
282        );
283        assert_eq!(extract_quoted(r#"  "module"  "#).unwrap(), "module");
284    }
285
286    #[test]
287    fn test_parse_dot_output() {
288        let dot = r#"
289digraph {
290  "my_crate" -> "my_crate::core"
291  "my_crate::core" -> "my_crate::utils"
292}
293"#;
294
295        let graph = parse_dot_output(dot).unwrap();
296        assert!(graph.nodes.contains("core"));
297        assert!(graph.nodes.contains("utils"));
298        assert!(graph.has_dependency("core", "utils"));
299    }
300
301    #[test]
302    fn test_import_graph_operations() {
303        let mut graph = ImportGraph::default();
304        graph.nodes.insert("core".to_string());
305        graph.nodes.insert("utils".to_string());
306        graph.edges.push(("core".to_string(), "utils".to_string()));
307
308        assert!(graph.has_dependency("core", "utils"));
309        assert!(!graph.has_dependency("utils", "core"));
310
311        let deps = graph.get_dependencies("core");
312        assert_eq!(deps, vec!["utils"]);
313    }
314
315    #[test]
316    fn test_validate_relationships_no_import() {
317        use archidoc_types::{C4Level, PatternStatus, Relationship};
318
319        let docs = vec![ModuleDoc {
320            module_path: "core".to_string(),
321            content: "test".to_string(),
322            source_file: "test.rs".to_string(),
323            c4_level: C4Level::Component,
324            pattern: "--".to_string(),
325            pattern_status: PatternStatus::Planned,
326            description: "test".to_string(),
327            parent_container: None,
328            relationships: vec![Relationship {
329                target: "utils".to_string(),
330                label: "test".to_string(),
331                protocol: "Rust".to_string(),
332            }],
333            files: vec![],
334        }];
335
336        let graph = ImportGraph::default(); // Empty graph
337
338        let warnings = validate_relationships(&docs, &graph);
339        assert_eq!(warnings.len(), 1);
340        assert_eq!(warnings[0].module, "core");
341        assert_eq!(warnings[0].target, "utils");
342        assert!(matches!(warnings[0].kind, WarningKind::NoImport));
343    }
344
345    #[test]
346    fn test_detect_orphans() {
347        use archidoc_types::{C4Level, PatternStatus};
348
349        let docs = vec![ModuleDoc {
350            module_path: "core".to_string(),
351            content: "test".to_string(),
352            source_file: "test.rs".to_string(),
353            c4_level: C4Level::Component,
354            pattern: "--".to_string(),
355            pattern_status: PatternStatus::Planned,
356            description: "test".to_string(),
357            parent_container: None,
358            relationships: vec![],
359            files: vec![],
360        }];
361
362        let mut graph = ImportGraph::default();
363        graph.nodes.insert("core".to_string());
364        graph.nodes.insert("utils".to_string());
365        graph.nodes.insert("database".to_string());
366
367        let orphans = detect_orphans(&docs, &graph);
368        assert_eq!(orphans.len(), 2);
369        assert!(orphans.contains(&"utils".to_string()));
370        assert!(orphans.contains(&"database".to_string()));
371    }
372
373    #[test]
374    fn test_check_cargo_modules_available() {
375        // This test will pass/fail based on whether cargo-modules is installed
376        // It's informational - won't fail the build
377        let available = check_cargo_modules_available();
378        println!("cargo-modules available: {}", available);
379    }
380}