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::{grammar, 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_grammar() -> crate::deps::Grammar {
337 grammar(ModsCheck::command())
338}
339
340pub fn check(args: &[String], root: &Path, timeout: Option<Duration>) -> (ProbeOutcome, String, String) {
345 let started = Instant::now();
346 let broken = |msg: String| (ProbeOutcome::Broken, msg, String::new());
347 let cli = match ModsCheck::try_parse_from(args.iter().map(String::as_str)) {
348 Ok(c) => c,
349 Err(e) => {
350 let valid = check_grammar().flags.iter().map(|s| format!("--{}", s.name)).collect::<Vec<_>>().join(" ");
351 return broken(format!(
352 "mods: {} (valid flags: {valid})",
353 e.to_string().lines().next().unwrap_or("bad arguments")
354 ));
355 }
356 };
357 if cli.forbid.is_empty() && !cli.acyclic && cli.layers.is_empty() {
358 return broken("mods: nothing to assert (--forbid/--acyclic/--layers)".to_string());
359 }
360 if cli.layers_closed && cli.layers.is_empty() {
361 return broken("mods: --layers-closed requires --layers".to_string());
362 }
363 let forbids: Vec<(String, String)> = match cli
364 .forbid
365 .iter()
366 .map(|spec| {
367 spec.split_once("=>")
368 .map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
369 .filter(|(a, b)| !a.is_empty() && !b.is_empty())
370 .ok_or_else(|| format!("mods: --forbid needs 'A=>B', got '{spec}'"))
371 })
372 .collect()
373 {
374 Ok(f) => f,
375 Err(e) => return broken(e),
376 };
377
378 let mut name_spec = cli.name.clone().unwrap_or_default();
379 let exts: Vec<String> = if cli.ext.is_empty() { vec!["rs".to_string()] } else { cli.ext.clone() };
380 for e in &exts {
381 let e = e.trim().trim_start_matches('.');
382 if e.is_empty() {
383 continue;
384 }
385 if !name_spec.is_empty() {
386 name_spec.push('|');
387 }
388 name_spec.push_str(&format!("*.{e}"));
389 }
390 let names = match pattern::compile_name_set(&name_spec) {
391 Ok(n) => n,
392 Err(e) => return broken(format!("mods: invalid --name/--ext: {e}")),
393 };
394 let base = root.join(&cli.base);
395 let selector = walk::Selector {
396 base: base.clone(),
397 names: Some(names),
398 types: vec![EntryType::F],
399 size: None,
400 hidden: cli.hidden,
401 follow: cli.follow,
402 no_ignore: false,
404 };
405 let mut files: Vec<(String, String)> = Vec::new();
406 for entry in selector.walk() {
407 if let Some(limit) = timeout
410 && started.elapsed() >= limit
411 {
412 return broken(format!("mods: timed out after {:.1}s", limit.as_secs_f64()));
413 }
414 let entry = match entry {
415 Ok(e) => e,
416 Err(e) => return broken(format!("mods: {e}")),
417 };
418 if !entry.file_type().is_some_and(|t| t.is_file()) {
419 continue;
420 }
421 let path = entry.path();
422 let rel = path.strip_prefix(&base).unwrap_or(path);
423 let Ok(text) = std::fs::read_to_string(path) else {
424 continue; };
426 files.push((module_name(rel), text));
427 }
428 if files.is_empty() {
429 return broken(format!("mods: no source files under {}", base.display()));
430 }
431 let graph = build_graph(&files);
432
433 let allowed: HashSet<EdgeKind> = [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect();
434 let mut violations: Vec<crate::deps::Violation> = Vec::new();
435 for (from, to) in &forbids {
436 match crate::deps::forbid_path(&graph, from, to, &allowed) {
437 Ok(v) => violations.extend(v),
438 Err(e) => return broken(format!("mods: {e}")),
439 }
440 }
441 if cli.acyclic {
442 violations.extend(crate::deps::cycles(&graph, &allowed, false));
443 }
444 if !cli.layers.is_empty() {
445 let compiled = match cli.layers.iter().map(|p| pattern::compile_anchored(p)).collect::<Result<Vec<_>, _>>() {
446 Ok(c) => c,
447 Err(e) => return broken(format!("mods: --layers invalid pattern: {e}")),
448 };
449 let (layers, unassigned) =
450 match crate::deps::assign_layers(&graph, &cli.layers, |i, n| compiled[i].is_match(n)) {
451 Ok(r) => r,
452 Err(e) => return broken(format!("mods: --layers: {e}")),
453 };
454 violations.extend(crate::deps::layer_violations(&graph, &layers, &allowed));
455 if cli.layers_closed {
456 violations.extend(unassigned.into_iter().map(|name| crate::deps::Violation {
457 check: "layers-closed".to_string(),
458 subject: name,
459 evidence: "matches no layer".to_string(),
460 }));
461 }
462 }
463
464 crate::deps::report_outcome("mods", violations)
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::deps::{self, EdgeKind};
471
472 fn all_edges() -> HashSet<EdgeKind> {
473 [EdgeKind::Normal, EdgeKind::Build, EdgeKind::Dev].into_iter().collect()
474 }
475
476 #[test]
477 fn use_targets_handles_the_common_forms() {
478 let src = r#"
479 // a leading comment with the word use in it
480 use std::collections::HashMap; // external: dropped
481 use crate::domain::Entity;
482 pub use crate::infra::{Db, cache::Lru}; // re-export + nested brace
483 use self::helpers::go as g; // self + alias
484 use super::sibling::Thing;
485 use crate::a::{self, b}; // self segment (folds in resolve)
486 fn body() {
487 use crate::late::Local; // own-line local import: counts
488 }
489 "#;
490 let mut t = use_targets(src);
491 t.sort();
492 assert_eq!(
495 t,
496 vec![
497 "crate::a::b",
498 "crate::a::self",
499 "crate::domain::Entity",
500 "crate::infra::Db",
501 "crate::infra::cache::Lru",
502 "crate::late::Local",
503 "self::helpers::go",
504 "super::sibling::Thing",
505 ]
506 );
507 }
508
509 #[test]
510 fn use_targets_joins_multiline_groups() {
511 let src = "use crate::a::{\n b,\n c::d,\n};\n";
512 let mut t = use_targets(src);
513 t.sort();
514 assert_eq!(t, vec!["crate::a::b", "crate::a::c::d"]);
515 }
516
517 #[test]
518 fn resolve_picks_the_longest_known_module() {
519 let modules: HashSet<&str> = ["crate", "a", "a::b", "domain"].into_iter().collect();
520 assert_eq!(resolve("crate::a::b::Item", &[], &modules).as_deref(), Some("a::b"));
522 assert_eq!(resolve("crate::a::Item", &[], &modules).as_deref(), Some("a"));
523 let cur = name_segs("a::b");
525 assert_eq!(resolve("super::Item", &cur, &modules).as_deref(), Some("a"));
526 assert_eq!(resolve("self::Item", &cur, &modules).as_deref(), Some("a::b"));
527 assert_eq!(resolve("crate::TopItem", &[], &modules).as_deref(), Some("crate"));
529 assert_eq!(resolve("serde::Deserialize", &[], &modules), None);
530 }
531
532 fn sample_crate() -> Vec<(String, String)> {
534 vec![
535 ("crate".into(), "mod domain;\nmod infra;\nuse crate::domain::Entity;\n".into()),
536 ("domain".into(), "use crate::infra::Db;\npub struct Entity;\n".into()),
537 ("infra".into(), "pub struct Db;\n".into()),
538 ]
539 }
540
541 #[test]
542 fn build_graph_edges_and_forbid() {
543 let g = build_graph(&sample_crate());
544 let v = deps::forbid_path(&g, "domain", "infra", &all_edges()).unwrap().unwrap();
546 assert_eq!(v.subject, "domain=>infra");
547 assert_eq!(v.evidence, "domain -> infra");
548 assert!(deps::forbid_path(&g, "infra", "domain", &all_edges()).unwrap().is_none());
549 }
550
551 #[test]
552 fn build_graph_layers_flag_an_upward_module_edge() {
553 let g = build_graph(&sample_crate());
554 let labels = vec!["infra".to_string(), "domain".to_string()];
556 let (layers, _) = deps::assign_layers(&g, &labels, |i, name| labels[i] == name).unwrap();
557 let viol = deps::layer_violations(&g, &layers, &all_edges());
558 assert_eq!(viol.len(), 1);
559 assert_eq!(viol[0].subject, "domain => infra");
560 assert_eq!(viol[0].evidence, "domain -> infra");
561 }
562
563 #[test]
564 fn build_graph_detects_a_module_cycle() {
565 let files = vec![
566 ("crate".into(), "mod a;\nmod b;\n".to_string()),
567 ("a".into(), "use crate::b::Thing;\n".to_string()),
568 ("b".into(), "use crate::a::Other;\n".to_string()),
569 ];
570 let g = build_graph(&files);
571 let cycles = deps::cycles(&g, &all_edges(), false);
572 assert_eq!(cycles.len(), 1);
573 assert_eq!(cycles[0].subject, "a, b");
574 assert_eq!(cycles[0].evidence, "a -> b -> a");
575 }
576}