1use std::collections::{BTreeSet, HashMap, HashSet};
31use std::path::Path;
32use std::sync::OnceLock;
33use std::time::{Duration, Instant};
34
35use clap::{CommandFactory, Parser};
36use regex::Regex;
37
38use crate::deps::{flag_kinds, EdgeKind, Graph, Package};
39use crate::pattern;
40use crate::rules::ProbeOutcome;
41use crate::walk::{self, EntryType};
42
43pub fn module_name(rel: &Path) -> String {
60 let mut segs: Vec<String> = rel
61 .components()
62 .filter_map(|c| match c {
63 std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
64 _ => None,
65 })
66 .collect();
67 if let Some(file) = segs.pop() {
68 let stem = Path::new(&file)
69 .file_stem()
70 .map(|s| s.to_string_lossy().into_owned())
71 .unwrap_or(file);
72 if stem != "mod" && stem != "lib" && stem != "main" {
73 segs.push(stem);
74 }
75 }
76 if segs.is_empty() {
77 "crate".to_string()
78 } else {
79 segs.join("::")
80 }
81}
82
83pub fn build_graph(files: &[(String, String)]) -> Graph {
87 let modules: HashSet<&str> = files.iter().map(|(n, _)| n.as_str()).collect();
88 let mut packages = HashMap::new();
89 let mut edges: HashMap<String, Vec<(String, Vec<EdgeKind>)>> = HashMap::new();
90 let mut members: Vec<String> = Vec::new();
91 for (name, content) in files {
92 packages.insert(
93 name.clone(),
94 Package { name: name.clone(), version: String::new() },
95 );
96 members.push(name.clone());
97 let current = name_segs(name);
98 let mut targets: BTreeSet<String> = BTreeSet::new();
99 for raw in use_targets(content) {
100 if let Some(t) = resolve(&raw, ¤t, &modules)
101 && t != *name
102 {
103 targets.insert(t);
104 }
105 }
106 edges.insert(
107 name.clone(),
108 targets.into_iter().map(|t| (t, vec![EdgeKind::Normal])).collect(),
109 );
110 }
111 members.sort();
112 members.dedup();
113 Graph { packages, edges, members }
114}
115
116pub fn use_targets(content: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 for stmt in use_statements(content) {
122 for leaf in expand_braces(&strip_aliases(&stmt)) {
123 let leaf = leaf.trim();
124 let head = leaf.split("::").next().unwrap_or("");
125 if matches!(head, "crate" | "self" | "super") {
126 out.push(leaf.to_string());
127 }
128 }
129 }
130 out
131}
132
133fn resolve(raw: &str, current: &[String], modules: &HashSet<&str>) -> Option<String> {
137 let parts: Vec<&str> = raw.split("::").map(str::trim).filter(|s| !s.is_empty()).collect();
138 let mut abs: Vec<String> = Vec::new();
139 let mut i = 0;
140 match *parts.first()? {
141 "crate" => i = 1,
142 "self" => {
143 abs = current.to_vec();
144 i = 1;
145 }
146 "super" => {
147 abs = current.to_vec();
148 while parts.get(i) == Some(&"super") {
149 abs.pop();
150 i += 1;
151 }
152 }
153 _ => return None, }
155 for p in &parts[i..] {
156 if *p == "self" || *p == "*" {
157 continue; }
159 abs.push((*p).to_string());
160 }
161 loop {
163 let name = if abs.is_empty() { "crate".to_string() } else { abs.join("::") };
164 if modules.contains(name.as_str()) {
165 return Some(name);
166 }
167 abs.pop()?;
168 }
169}
170
171fn name_segs(name: &str) -> Vec<String> {
173 if name == "crate" {
174 Vec::new()
175 } else {
176 name.split("::").map(String::from).collect()
177 }
178}
179
180fn use_statements(content: &str) -> Vec<String> {
183 let mut stmts = Vec::new();
184 let mut lines = content.lines();
185 while let Some(line) = lines.next() {
186 let Some(rest) = strip_vis_use(line) else {
187 continue;
188 };
189 let mut body = rest.to_string();
190 loop {
191 if let Some(idx) = body.find(';') {
192 body.truncate(idx);
193 stmts.push(body);
194 break;
195 }
196 match lines.next() {
197 Some(next) => {
198 body.push(' ');
199 body.push_str(next.trim());
200 }
201 None => {
202 stmts.push(body);
203 break;
204 }
205 }
206 }
207 }
208 stmts
209}
210
211fn strip_vis_use(line: &str) -> Option<&str> {
214 let t = line.trim_start();
215 let after_vis = if let Some(r) = t.strip_prefix("pub") {
216 let r = r.trim_start();
217 let r = if r.starts_with('(') {
218 r.find(')').map(|i| &r[i + 1..]).unwrap_or(r)
219 } else {
220 r
221 };
222 r.trim_start()
223 } else {
224 t
225 };
226 after_vis
227 .strip_prefix("use")
228 .filter(|r| r.starts_with(char::is_whitespace))
229 .map(str::trim_start)
230}
231
232fn strip_aliases(s: &str) -> std::borrow::Cow<'_, str> {
234 static RE: OnceLock<Regex> = OnceLock::new();
235 let re = RE.get_or_init(|| Regex::new(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*").unwrap());
236 re.replace_all(s, "")
237}
238
239fn expand_braces(s: &str) -> Vec<String> {
241 let s = s.trim();
242 match s.find('{') {
243 None => {
244 if s.is_empty() {
245 vec![]
246 } else {
247 vec![s.to_string()]
248 }
249 }
250 Some(open) => {
251 let Some(close) = matching_brace(s.as_bytes(), open) else {
252 return vec![s.to_string()]; };
254 let prefix = &s[..open];
255 let inner = &s[open + 1..close];
256 let suffix = &s[close + 1..];
257 let mut out = Vec::new();
258 for part in split_top_commas(inner) {
259 let part = part.trim();
260 if part.is_empty() {
261 continue;
262 }
263 out.extend(expand_braces(&format!("{prefix}{part}{suffix}")));
264 }
265 out
266 }
267 }
268}
269
270fn matching_brace(bytes: &[u8], open: usize) -> Option<usize> {
272 let mut depth = 0usize;
273 for (i, &b) in bytes.iter().enumerate().skip(open) {
274 match b {
275 b'{' => depth += 1,
276 b'}' => {
277 depth -= 1;
278 if depth == 0 {
279 return Some(i);
280 }
281 }
282 _ => {}
283 }
284 }
285 None
286}
287
288fn split_top_commas(s: &str) -> Vec<&str> {
290 let mut parts = Vec::new();
291 let mut depth = 0i32;
292 let mut start = 0;
293 for (i, c) in s.char_indices() {
294 match c {
295 '{' => depth += 1,
296 '}' => depth -= 1,
297 ',' if depth == 0 => {
298 parts.push(&s[start..i]);
299 start = i + 1;
300 }
301 _ => {}
302 }
303 }
304 parts.push(&s[start..]);
305 parts
306}
307
308#[derive(Parser, Debug)]
313#[command(no_binary_name = true, disable_help_flag = true)]
314struct ModsCheck {
315 #[arg(long, default_value = "src")]
316 base: String,
317 #[arg(long)]
318 name: Option<String>,
319 #[arg(long, value_delimiter = ',')]
320 ext: Vec<String>,
321 #[arg(long)]
322 hidden: bool,
323 #[arg(long)]
324 follow: bool,
325 #[arg(long, value_name = "A=>B")]
326 forbid: Vec<String>,
327 #[arg(long)]
328 acyclic: bool,
329 #[arg(long, value_name = "L0,L1,...", value_delimiter = ',')]
330 layers: Vec<String>,
331 #[arg(long)]
332 layers_closed: bool,
333}
334
335pub fn check_flags() -> Vec<(String, &'static str)> {
340 flag_kinds(ModsCheck::command())
341}
342
343pub fn check(args: &[String], root: &Path, timeout: Option<Duration>) -> (ProbeOutcome, String, String) {
348 let started = Instant::now();
349 let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
350 let cli = match ModsCheck::try_parse_from(args.iter().map(String::as_str)) {
351 Ok(c) => c,
352 Err(e) => {
353 let valid = check_flags().iter().map(|(f, _)| format!("--{f}")).collect::<Vec<_>>().join(" ");
354 return broken(format!(
355 "mods: {} (valid flags: {valid})",
356 e.to_string().lines().next().unwrap_or("bad arguments")
357 ));
358 }
359 };
360 if cli.forbid.is_empty() && !cli.acyclic && cli.layers.is_empty() {
361 return broken("mods: nothing to assert (--forbid/--acyclic/--layers)".to_string());
362 }
363 if cli.layers_closed && cli.layers.is_empty() {
364 return broken("mods: --layers-closed requires --layers".to_string());
365 }
366 let forbids: Vec<(String, String)> = match cli
367 .forbid
368 .iter()
369 .map(|spec| {
370 spec.split_once("=>")
371 .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
372 .filter(|(a, b)| !a.is_empty() && !b.is_empty())
373 .ok_or_else(|| format!("mods: --forbid needs 'A=>B', got '{spec}'"))
374 })
375 .collect()
376 {
377 Ok(f) => f,
378 Err(e) => return broken(e),
379 };
380
381 let mut name_spec = cli.name.clone().unwrap_or_default();
382 let exts: Vec<String> = if cli.ext.is_empty() { vec!["rs".to_string()] } else { cli.ext.clone() };
383 for e in &exts {
384 let e = e.trim().trim_start_matches('.');
385 if e.is_empty() {
386 continue;
387 }
388 if !name_spec.is_empty() {
389 name_spec.push('|');
390 }
391 name_spec.push_str(&format!("*.{e}"));
392 }
393 let names = match pattern::compile_name_set(&name_spec) {
394 Ok(n) => n,
395 Err(e) => return broken(format!("mods: invalid --name/--ext: {e}")),
396 };
397 let base = root.join(&cli.base);
398 let selector = walk::Selector {
399 base: base.clone(),
400 names: Some(names),
401 types: vec![EntryType::F],
402 size: None,
403 hidden: cli.hidden,
404 follow: cli.follow,
405 };
406 let mut files: Vec<(String, String)> = Vec::new();
407 for entry in selector.walk() {
408 if let Some(limit) = timeout
411 && started.elapsed() >= limit
412 {
413 return broken(format!("mods: timed out after {:.1}s", limit.as_secs_f64()));
414 }
415 let entry = match entry {
416 Ok(e) => e,
417 Err(e) => return broken(format!("mods: {e}")),
418 };
419 if !entry.file_type().is_file() {
420 continue;
421 }
422 let path = entry.path();
423 let rel = path.strip_prefix(&base).unwrap_or(path);
424 let Ok(text) = std::fs::read_to_string(path) else {
425 continue; };
427 files.push((module_name(rel), text));
428 }
429 if files.is_empty() {
430 return broken(format!("mods: no source files under {}", base.display()));
431 }
432 let graph = build_graph(&files);
433
434 let allowed: HashSet<EdgeKind> = [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect();
435 let mut violations: Vec<crate::deps::Violation> = Vec::new();
436 for (from, to) in &forbids {
437 match crate::deps::forbid_path(&graph, from, to, &allowed) {
438 Ok(v) => violations.extend(v),
439 Err(e) => return broken(format!("mods: {e}")),
440 }
441 }
442 if cli.acyclic {
443 violations.extend(crate::deps::cycles(&graph, &allowed, false));
444 }
445 if !cli.layers.is_empty() {
446 let compiled = match cli.layers.iter().map(|p| pattern::compile_anchored(p)).collect::<Result<Vec<_>, _>>() {
447 Ok(c) => c,
448 Err(e) => return broken(format!("mods: --layers invalid pattern: {e}")),
449 };
450 let (layers, unassigned) =
451 match crate::deps::assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
452 Ok(r) => r,
453 Err(e) => return broken(format!("mods: --layers: {e}")),
454 };
455 violations.extend(crate::deps::layer_violations(&graph, &layers, &allowed));
456 if cli.layers_closed {
457 violations.extend(unassigned.into_iter().map(|name| crate::deps::Violation {
458 check: "layers-closed".to_string(),
459 subject: name,
460 evidence: "matches no layer".to_string(),
461 }));
462 }
463 }
464
465 crate::deps::report_outcome("mods", violations)
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::deps::{self, EdgeKind};
472
473 fn all_edges() -> HashSet<EdgeKind> {
474 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect()
475 }
476
477 #[test]
478 fn use_targets_handles_the_common_forms() {
479 let src = r#"
480 // a leading comment with the word use in it
481 use std::collections::HashMap; // external: dropped
482 use crate::domain::Entity;
483 pub use crate::infra::{Db, cache::Lru}; // re-export + nested brace
484 use self::helpers::go as g; // self + alias
485 use super::sibling::Thing;
486 use crate::a::{self, b}; // self segment (folds in resolve)
487 fn body() {
488 use crate::late::Local; // own-line local import: counts
489 }
490 "#;
491 let mut t = use_targets(src);
492 t.sort();
493 assert_eq!(
496 t,
497 vec![
498 "crate::a::b",
499 "crate::a::self",
500 "crate::domain::Entity",
501 "crate::infra::Db",
502 "crate::infra::cache::Lru",
503 "crate::late::Local",
504 "self::helpers::go",
505 "super::sibling::Thing",
506 ]
507 );
508 }
509
510 #[test]
511 fn use_targets_joins_multiline_groups() {
512 let src = "use crate::a::{\n b,\n c::d,\n};\n";
513 let mut t = use_targets(src);
514 t.sort();
515 assert_eq!(t, vec!["crate::a::b", "crate::a::c::d"]);
516 }
517
518 #[test]
519 fn resolve_picks_the_longest_known_module() {
520 let modules: HashSet<&str> = ["crate", "a", "a::b", "domain"].into_iter().collect();
521 assert_eq!(resolve("crate::a::b::Item", &[], &modules).as_deref(), Some("a::b"));
523 assert_eq!(resolve("crate::a::Item", &[], &modules).as_deref(), Some("a"));
524 let cur = name_segs("a::b");
526 assert_eq!(resolve("super::Item", &cur, &modules).as_deref(), Some("a"));
527 assert_eq!(resolve("self::Item", &cur, &modules).as_deref(), Some("a::b"));
528 assert_eq!(resolve("crate::TopItem", &[], &modules).as_deref(), Some("crate"));
530 assert_eq!(resolve("serde::Deserialize", &[], &modules), None);
531 }
532
533 fn sample_crate() -> Vec<(String, String)> {
535 vec![
536 ("crate".into(), "mod domain;\nmod infra;\nuse crate::domain::Entity;\n".into()),
537 ("domain".into(), "use crate::infra::Db;\npub struct Entity;\n".into()),
538 ("infra".into(), "pub struct Db;\n".into()),
539 ]
540 }
541
542 #[test]
543 fn build_graph_edges_and_forbid() {
544 let g = build_graph(&sample_crate());
545 let v = deps::forbid_path(&g, "domain", "infra", &all_edges()).unwrap().unwrap();
547 assert_eq!(v.subject, "domain=>infra");
548 assert_eq!(v.evidence, "domain -> infra");
549 assert!(deps::forbid_path(&g, "infra", "domain", &all_edges()).unwrap().is_none());
550 }
551
552 #[test]
553 fn build_graph_layers_flag_an_upward_module_edge() {
554 let g = build_graph(&sample_crate());
555 let labels = vec!["infra".to_string(), "domain".to_string()];
557 let (layers, _) = deps::assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
558 let viol = deps::layer_violations(&g, &layers, &all_edges());
559 assert_eq!(viol.len(), 1);
560 assert_eq!(viol[0].subject, "domain => infra");
561 assert_eq!(viol[0].evidence, "domain -> infra");
562 }
563
564 #[test]
565 fn build_graph_detects_a_module_cycle() {
566 let files = vec![
567 ("crate".into(), "mod a;\nmod b;\n".to_string()),
568 ("a".into(), "use crate::b::Thing;\n".to_string()),
569 ("b".into(), "use crate::a::Other;\n".to_string()),
570 ];
571 let g = build_graph(&files);
572 let cycles = deps::cycles(&g, &all_edges(), false);
573 assert_eq!(cycles.len(), 1);
574 assert_eq!(cycles[0].subject, "a, b");
575 assert_eq!(cycles[0].evidence, "a -> b -> a");
576 }
577}