1use archidoc_types::ModuleDoc;
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use std::process::Command;
14
15pub 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#[derive(Debug, Clone, Default)]
30pub struct ImportGraph {
31 pub nodes: HashSet<String>,
33 pub edges: Vec<(String, String)>,
35}
36
37impl ImportGraph {
38 pub fn has_dependency(&self, from: &str, to: &str) -> bool {
40 self.edges.iter().any(|(f, t)| f == from && t == to)
41 }
42
43 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
53pub 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
76fn 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 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 let from = extract_quoted(from_part)?;
99 let to = extract_quoted(to_part)?;
100
101 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
114fn 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
124fn crate_path_to_module(path: &str) -> String {
131 let parts: Vec<&str> = path.split("::").collect();
132
133 if parts.len() <= 1 {
134 return parts[0].to_string();
136 }
137
138 parts[1..].join(".")
140}
141
142#[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 NoImport,
154 Undeclared,
156}
157
158pub fn validate_relationships(
162 docs: &[ModuleDoc],
163 graph: &ImportGraph,
164) -> Vec<RelationshipWarning> {
165 let mut warnings = Vec::new();
166
167 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 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 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 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
214pub 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
231pub 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 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(); 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 let available = check_cargo_modules_available();
378 println!("cargo-modules available: {}", available);
379 }
380}