1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt::Write as _,
4 fs, io,
5 path::{Path, PathBuf},
6};
7
8use glob::{Pattern, glob};
9use serde::Serialize;
10use walkdir::WalkDir;
11
12mod api_shape;
13mod diagnostic;
14mod namespace;
15
16pub use diagnostic::{
17 Diagnostic, DiagnosticClass, DiagnosticFix, DiagnosticFixKind, DiagnosticLevel,
18 DiagnosticSelection,
19};
20
21const DEFAULT_GENERIC_NOUNS: &[&str] = &[
22 "Id",
23 "Repository",
24 "Service",
25 "Error",
26 "Command",
27 "Request",
28 "Response",
29 "Outcome",
30];
31
32const DEFAULT_WEAK_MODULES: &[&str] = &[
33 "storage",
34 "transport",
35 "infra",
36 "common",
37 "misc",
38 "helpers",
39 "helper",
40 "types",
41 "util",
42 "utils",
43];
44
45const DEFAULT_CATCH_ALL_MODULES: &[&str] = &[
46 "common", "misc", "helpers", "helper", "types", "util", "utils",
47];
48
49const DEFAULT_ORGANIZATIONAL_MODULES: &[&str] = &["error", "errors", "request", "response"];
50
51const DEFAULT_NAMESPACE_PRESERVING_MODULES: &[&str] = &[
52 "auth",
53 "command",
54 "components",
55 "email",
56 "error",
57 "http",
58 "page",
59 "partials",
60 "policy",
61 "query",
62 "repo",
63 "store",
64 "trace",
65 "storage",
66 "transport",
67 "infra",
68 "write_back",
69];
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct AnalysisResult {
73 pub diagnostics: Vec<Diagnostic>,
74}
75
76impl AnalysisResult {
77 fn empty() -> Self {
78 Self {
79 diagnostics: Vec::new(),
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct WorkspaceReport {
86 pub scanned_files: usize,
87 pub files_with_violations: usize,
88 pub diagnostics: Vec<Diagnostic>,
89}
90
91impl WorkspaceReport {
92 pub fn error_count(&self) -> usize {
93 self.diagnostics
94 .iter()
95 .filter(|diag| diag.is_error())
96 .count()
97 }
98
99 pub fn warning_count(&self) -> usize {
100 self.diagnostics
101 .iter()
102 .filter(|diag| !diag.is_error())
103 .count()
104 }
105
106 pub fn policy_warning_count(&self) -> usize {
107 self.diagnostics
108 .iter()
109 .filter(|diag| diag.is_policy_warning())
110 .count()
111 }
112
113 pub fn advisory_warning_count(&self) -> usize {
114 self.diagnostics
115 .iter()
116 .filter(|diag| diag.is_advisory_warning())
117 .count()
118 }
119
120 pub fn policy_violation_count(&self) -> usize {
121 self.diagnostics
122 .iter()
123 .filter(|diag| diag.is_policy_violation())
124 .count()
125 }
126
127 pub fn filtered(&self, selection: DiagnosticSelection) -> Self {
128 let diagnostics = self
129 .diagnostics
130 .iter()
131 .filter(|diag| selection.includes(diag))
132 .cloned()
133 .collect::<Vec<_>>();
134 let files_with_violations = diagnostics
135 .iter()
136 .filter_map(|diag| diag.file.as_ref())
137 .collect::<BTreeSet<_>>()
138 .len();
139
140 Self {
141 scanned_files: self.scanned_files,
142 files_with_violations,
143 diagnostics,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum CheckMode {
150 Off,
151 Warn,
152 Deny,
153}
154
155impl CheckMode {
156 pub fn parse(raw: &str) -> Result<Self, String> {
157 raw.parse()
158 }
159}
160
161impl std::str::FromStr for CheckMode {
162 type Err = String;
163
164 fn from_str(raw: &str) -> Result<Self, Self::Err> {
165 match raw {
166 "off" => Ok(Self::Off),
167 "warn" => Ok(Self::Warn),
168 "deny" => Ok(Self::Deny),
169 _ => Err(format!("invalid mode `{raw}`; expected off|warn|deny")),
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
175pub struct CheckOutcome {
176 pub report: WorkspaceReport,
177 pub exit_code: u8,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Default)]
181pub struct ScanSettings {
182 pub include: Vec<String>,
183 pub exclude: Vec<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
187struct NamespaceSettings {
188 generic_nouns: BTreeSet<String>,
189 weak_modules: BTreeSet<String>,
190 catch_all_modules: BTreeSet<String>,
191 organizational_modules: BTreeSet<String>,
192 namespace_preserving_modules: BTreeSet<String>,
193}
194
195impl Default for NamespaceSettings {
196 fn default() -> Self {
197 Self {
198 generic_nouns: DEFAULT_GENERIC_NOUNS
199 .iter()
200 .map(|noun| (*noun).to_string())
201 .collect(),
202 weak_modules: DEFAULT_WEAK_MODULES
203 .iter()
204 .map(|module| (*module).to_string())
205 .collect(),
206 catch_all_modules: DEFAULT_CATCH_ALL_MODULES
207 .iter()
208 .map(|module| (*module).to_string())
209 .collect(),
210 organizational_modules: DEFAULT_ORGANIZATIONAL_MODULES
211 .iter()
212 .map(|module| (*module).to_string())
213 .collect(),
214 namespace_preserving_modules: DEFAULT_NAMESPACE_PRESERVING_MODULES
215 .iter()
216 .map(|module| (*module).to_string())
217 .collect(),
218 }
219 }
220}
221
222pub fn parse_check_mode(raw: &str) -> Result<CheckMode, String> {
223 CheckMode::parse(raw)
224}
225
226pub fn run_check(root: &Path, include_globs: &[String], mode: CheckMode) -> CheckOutcome {
227 run_check_with_scan_settings(
228 root,
229 &ScanSettings {
230 include: include_globs.to_vec(),
231 exclude: Vec::new(),
232 },
233 mode,
234 )
235}
236
237pub fn run_check_with_scan_settings(
238 root: &Path,
239 scan_settings: &ScanSettings,
240 mode: CheckMode,
241) -> CheckOutcome {
242 if mode == CheckMode::Off {
243 return CheckOutcome {
244 report: WorkspaceReport {
245 scanned_files: 0,
246 files_with_violations: 0,
247 diagnostics: Vec::new(),
248 },
249 exit_code: 0,
250 };
251 }
252
253 let report = analyze_workspace_with_scan_settings(root, scan_settings);
254 let exit_code = check_exit_code(&report, mode);
255 CheckOutcome { report, exit_code }
256}
257
258fn check_exit_code(report: &WorkspaceReport, mode: CheckMode) -> u8 {
259 if report.error_count() > 0 {
260 return 1;
261 }
262
263 if report.policy_violation_count() == 0 || mode == CheckMode::Warn {
264 0
265 } else {
266 2
267 }
268}
269
270pub fn analyze_file(path: &Path, src: &str) -> AnalysisResult {
271 analyze_file_with_settings(path, src, &NamespaceSettings::default())
272}
273
274fn analyze_file_with_settings(
275 path: &Path,
276 src: &str,
277 settings: &NamespaceSettings,
278) -> AnalysisResult {
279 let parsed = match syn::parse_file(src) {
280 Ok(file) => file,
281 Err(err) => {
282 return AnalysisResult {
283 diagnostics: vec![Diagnostic::error(
284 Some(path.to_path_buf()),
285 None,
286 format!("failed to parse rust file: {err}"),
287 )],
288 };
289 }
290 };
291
292 let mut result = AnalysisResult::empty();
293 result
294 .diagnostics
295 .extend(namespace::analyze_namespace_rules(path, &parsed, settings).diagnostics);
296 result
297 .diagnostics
298 .extend(api_shape::analyze_api_shape_rules(path, &parsed, settings).diagnostics);
299 result.diagnostics.sort();
300 result
301}
302
303pub fn analyze_workspace(root: &Path, include_globs: &[String]) -> WorkspaceReport {
304 analyze_workspace_with_scan_settings(
305 root,
306 &ScanSettings {
307 include: include_globs.to_vec(),
308 exclude: Vec::new(),
309 },
310 )
311}
312
313pub fn analyze_workspace_with_scan_settings(
314 root: &Path,
315 cli_scan_settings: &ScanSettings,
316) -> WorkspaceReport {
317 let mut diagnostics = Vec::new();
318 let workspace_defaults = load_workspace_settings(root, &mut diagnostics);
319 let repo_scan_settings = load_repo_scan_settings(root, &mut diagnostics);
320 let effective_scan_settings = effective_scan_settings(&repo_scan_settings, cli_scan_settings);
321 let rust_files = match collect_rust_files(
322 root,
323 &effective_scan_settings.include,
324 &effective_scan_settings.exclude,
325 ) {
326 Ok(files) => files,
327 Err(err) => {
328 diagnostics.push(Diagnostic::error(
329 None,
330 None,
331 format!("failed to discover rust files: {err}"),
332 ));
333 return WorkspaceReport {
334 scanned_files: 0,
335 files_with_violations: 0,
336 diagnostics,
337 };
338 }
339 };
340
341 if rust_files.is_empty() {
342 diagnostics.push(Diagnostic::warning(
343 None,
344 None,
345 "no Rust files were discovered; pass --include <path>... or run from a crate/workspace root",
346 ));
347 }
348
349 let mut files_with_violations = BTreeSet::new();
350 let mut package_cache = BTreeMap::new();
351
352 for file in &rust_files {
353 let src = match fs::read_to_string(file) {
354 Ok(src) => src,
355 Err(err) => {
356 diagnostics.push(Diagnostic::error(
357 Some(file.clone()),
358 None,
359 format!("failed to read file: {err}"),
360 ));
361 continue;
362 }
363 };
364
365 let settings = settings_for_file(root, file, &workspace_defaults, &mut package_cache);
366 let analysis = analyze_file_with_settings(file, &src, &settings);
367 if !analysis.diagnostics.is_empty() {
368 files_with_violations.insert(file.clone());
369 }
370 diagnostics.extend(analysis.diagnostics);
371 }
372
373 diagnostics.sort();
374
375 WorkspaceReport {
376 scanned_files: rust_files.len(),
377 files_with_violations: files_with_violations.len(),
378 diagnostics,
379 }
380}
381
382fn effective_scan_settings(
383 repo_defaults: &ScanSettings,
384 cli_overrides: &ScanSettings,
385) -> ScanSettings {
386 let include = if cli_overrides.include.is_empty() {
387 repo_defaults.include.clone()
388 } else {
389 cli_overrides.include.clone()
390 };
391 let mut exclude = repo_defaults.exclude.clone();
392 exclude.extend(cli_overrides.exclude.iter().cloned());
393 ScanSettings { include, exclude }
394}
395
396fn load_workspace_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> NamespaceSettings {
397 let manifest_path = root.join("Cargo.toml");
398 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
399 return NamespaceSettings::default();
400 };
401
402 let manifest: toml::Value = match toml::from_str(&manifest_src) {
403 Ok(manifest) => manifest,
404 Err(err) => {
405 diagnostics.push(Diagnostic::error(
406 Some(manifest_path),
407 None,
408 format!("failed to parse Cargo.toml for modum settings: {err}"),
409 ));
410 return NamespaceSettings::default();
411 }
412 };
413
414 parse_settings_from_manifest(
415 manifest
416 .get("workspace")
417 .and_then(toml::Value::as_table)
418 .and_then(|workspace| workspace.get("metadata"))
419 .and_then(toml::Value::as_table)
420 .and_then(|metadata| metadata.get("modum")),
421 &manifest_path,
422 diagnostics,
423 )
424 .unwrap_or_default()
425}
426
427fn load_repo_scan_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> ScanSettings {
428 let manifest_path = root.join("Cargo.toml");
429 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
430 return ScanSettings::default();
431 };
432
433 let manifest: toml::Value = match toml::from_str(&manifest_src) {
434 Ok(manifest) => manifest,
435 Err(err) => {
436 diagnostics.push(Diagnostic::error(
437 Some(manifest_path),
438 None,
439 format!("failed to parse Cargo.toml for modum settings: {err}"),
440 ));
441 return ScanSettings::default();
442 }
443 };
444
445 parse_scan_settings_from_manifest(
446 manifest
447 .get("workspace")
448 .and_then(toml::Value::as_table)
449 .and_then(|workspace| workspace.get("metadata"))
450 .and_then(toml::Value::as_table)
451 .and_then(|metadata| metadata.get("modum"))
452 .or_else(|| {
453 manifest
454 .get("package")
455 .and_then(toml::Value::as_table)
456 .and_then(|package| package.get("metadata"))
457 .and_then(toml::Value::as_table)
458 .and_then(|metadata| metadata.get("modum"))
459 }),
460 &manifest_path,
461 diagnostics,
462 )
463 .unwrap_or_default()
464}
465
466fn settings_for_file(
467 root: &Path,
468 file: &Path,
469 workspace_defaults: &NamespaceSettings,
470 cache: &mut BTreeMap<PathBuf, NamespaceSettings>,
471) -> NamespaceSettings {
472 let Some(package_root) = find_package_root(root, file) else {
473 return workspace_defaults.clone();
474 };
475
476 cache
477 .entry(package_root.clone())
478 .or_insert_with(|| load_package_settings(&package_root, workspace_defaults))
479 .clone()
480}
481
482fn load_package_settings(root: &Path, workspace_defaults: &NamespaceSettings) -> NamespaceSettings {
483 let manifest_path = root.join("Cargo.toml");
484 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
485 return workspace_defaults.clone();
486 };
487 let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src) else {
488 return workspace_defaults.clone();
489 };
490
491 parse_settings_from_manifest(
492 manifest
493 .get("package")
494 .and_then(toml::Value::as_table)
495 .and_then(|package| package.get("metadata"))
496 .and_then(toml::Value::as_table)
497 .and_then(|metadata| metadata.get("modum")),
498 &manifest_path,
499 &mut Vec::new(),
500 )
501 .unwrap_or_else(|| workspace_defaults.clone())
502}
503
504fn parse_settings_from_manifest(
505 value: Option<&toml::Value>,
506 manifest_path: &Path,
507 diagnostics: &mut Vec<Diagnostic>,
508) -> Option<NamespaceSettings> {
509 let table = value?.as_table()?;
510 let mut settings = NamespaceSettings::default();
511
512 if let Some(values) = parse_string_set_field(table, "generic_nouns", manifest_path, diagnostics)
513 {
514 settings.generic_nouns = values;
515 }
516 if let Some(values) = parse_string_set_field(table, "weak_modules", manifest_path, diagnostics)
517 {
518 settings.weak_modules = values;
519 }
520 if let Some(values) =
521 parse_string_set_field(table, "catch_all_modules", manifest_path, diagnostics)
522 {
523 settings.catch_all_modules = values;
524 }
525 if let Some(values) =
526 parse_string_set_field(table, "organizational_modules", manifest_path, diagnostics)
527 {
528 settings.organizational_modules = values;
529 }
530 if let Some(values) = parse_string_set_field(
531 table,
532 "namespace_preserving_modules",
533 manifest_path,
534 diagnostics,
535 ) {
536 settings.namespace_preserving_modules = values;
537 }
538
539 Some(settings)
540}
541
542fn parse_scan_settings_from_manifest(
543 value: Option<&toml::Value>,
544 manifest_path: &Path,
545 diagnostics: &mut Vec<Diagnostic>,
546) -> Option<ScanSettings> {
547 let table = value?.as_table()?;
548 let mut settings = ScanSettings::default();
549
550 if let Some(values) = parse_string_list_field(table, "include", manifest_path, diagnostics) {
551 settings.include = values;
552 }
553 if let Some(values) = parse_string_list_field(table, "exclude", manifest_path, diagnostics) {
554 settings.exclude = values;
555 }
556
557 Some(settings)
558}
559
560fn parse_string_set_field(
561 table: &toml::value::Table,
562 key: &str,
563 manifest_path: &Path,
564 diagnostics: &mut Vec<Diagnostic>,
565) -> Option<BTreeSet<String>> {
566 let value = table.get(key)?;
567 let Some(array) = value.as_array() else {
568 diagnostics.push(Diagnostic::error(
569 Some(manifest_path.to_path_buf()),
570 None,
571 format!("`metadata.modum.{key}` must be an array of strings"),
572 ));
573 return None;
574 };
575
576 Some(
577 array
578 .iter()
579 .filter_map(toml::Value::as_str)
580 .map(|value| value.to_string())
581 .collect(),
582 )
583}
584
585fn parse_string_list_field(
586 table: &toml::value::Table,
587 key: &str,
588 manifest_path: &Path,
589 diagnostics: &mut Vec<Diagnostic>,
590) -> Option<Vec<String>> {
591 let value = table.get(key)?;
592 let Some(array) = value.as_array() else {
593 diagnostics.push(Diagnostic::error(
594 Some(manifest_path.to_path_buf()),
595 None,
596 format!("`metadata.modum.{key}` must be an array of strings"),
597 ));
598 return None;
599 };
600
601 Some(
602 array
603 .iter()
604 .filter_map(toml::Value::as_str)
605 .map(|value| value.to_string())
606 .collect(),
607 )
608}
609
610fn find_package_root(root: &Path, file: &Path) -> Option<PathBuf> {
611 for ancestor in file.ancestors().skip(1) {
612 let manifest_path = ancestor.join("Cargo.toml");
613 if manifest_path.is_file()
614 && let Ok(manifest_src) = fs::read_to_string(&manifest_path)
615 && let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src)
616 && manifest.get("package").is_some_and(toml::Value::is_table)
617 {
618 return Some(ancestor.to_path_buf());
619 }
620 if ancestor == root {
621 break;
622 }
623 }
624 None
625}
626
627pub fn render_pretty_report(report: &WorkspaceReport) -> String {
628 render_pretty_report_with_selection(report, DiagnosticSelection::All)
629}
630
631pub fn render_pretty_report_with_selection(
632 report: &WorkspaceReport,
633 selection: DiagnosticSelection,
634) -> String {
635 let filtered = report.filtered(selection);
636 let mut out = String::new();
637
638 let _ = writeln!(&mut out, "modum lint report");
639 let _ = writeln!(&mut out, "files scanned: {}", filtered.scanned_files);
640 let _ = writeln!(
641 &mut out,
642 "files with violations: {}",
643 filtered.files_with_violations
644 );
645 let _ = writeln!(
646 &mut out,
647 "diagnostics: {} error(s), {} policy warning(s), {} advisory warning(s)",
648 filtered.error_count(),
649 filtered.policy_warning_count(),
650 filtered.advisory_warning_count()
651 );
652 if let Some(selection_label) = selection.report_label() {
653 let _ = writeln!(
654 &mut out,
655 "showing: {selection_label} (exit code still reflects the full report)"
656 );
657 }
658 if filtered.policy_violation_count() > 0 {
659 let _ = writeln!(
660 &mut out,
661 "policy violations: {}",
662 filtered.policy_violation_count()
663 );
664 }
665 if filtered.advisory_warning_count() > 0 {
666 let _ = writeln!(
667 &mut out,
668 "advisories: {}",
669 filtered.advisory_warning_count()
670 );
671 }
672
673 if !filtered.diagnostics.is_empty() {
674 let _ = writeln!(&mut out);
675 render_diagnostic_section(
676 &mut out,
677 "Errors:",
678 filtered.diagnostics.iter().filter(|diag| diag.is_error()),
679 );
680 render_diagnostic_section(
681 &mut out,
682 "Policy Diagnostics:",
683 filtered
684 .diagnostics
685 .iter()
686 .filter(|diag| diag.is_policy_warning()),
687 );
688 render_diagnostic_section(
689 &mut out,
690 "Advisory Diagnostics:",
691 filtered
692 .diagnostics
693 .iter()
694 .filter(|diag| diag.is_advisory_warning()),
695 );
696 }
697
698 out
699}
700
701fn render_diagnostic_section<'a>(
702 out: &mut String,
703 title: &str,
704 diagnostics: impl Iterator<Item = &'a Diagnostic>,
705) {
706 let diagnostics = diagnostics.collect::<Vec<_>>();
707 if diagnostics.is_empty() {
708 return;
709 }
710
711 let _ = writeln!(out, "{title}");
712 for diag in diagnostics {
713 let level = match diag.level() {
714 DiagnosticLevel::Warning => "warning",
715 DiagnosticLevel::Error => "error",
716 };
717 let code = diag
718 .code()
719 .map(|code| format!(" ({code})"))
720 .unwrap_or_default();
721 match (&diag.file, diag.line) {
722 (Some(file), Some(line)) => {
723 let _ = writeln!(
724 out,
725 "- [{level}{code}] {}:{line}: {}",
726 file.display(),
727 diag.message
728 );
729 }
730 (Some(file), None) => {
731 let _ = writeln!(
732 out,
733 "- [{level}{code}] {}: {}",
734 file.display(),
735 diag.message
736 );
737 }
738 (None, _) => {
739 let _ = writeln!(out, "- [{level}{code}] {}", diag.message);
740 }
741 }
742 }
743 let _ = writeln!(out);
744}
745
746fn collect_rust_files(
747 root: &Path,
748 include_globs: &[String],
749 exclude_globs: &[String],
750) -> io::Result<Vec<PathBuf>> {
751 let mut files = BTreeSet::new();
752 if include_globs.is_empty() {
753 for scan_root in collect_default_scan_roots(root)? {
754 collect_rust_files_in_dir(&scan_root, &mut files);
755 }
756 } else {
757 for entry in include_globs {
758 collect_rust_files_for_entry(root, entry, &mut files)?;
759 }
760 }
761 files.retain(|path| !is_excluded_path(root, path, exclude_globs).unwrap_or(false));
762 Ok(files.into_iter().collect())
763}
764
765fn collect_rust_files_for_entry(
766 root: &Path,
767 entry: &str,
768 files: &mut BTreeSet<PathBuf>,
769) -> io::Result<()> {
770 let candidate = root.join(entry);
771 if !contains_glob_meta(entry) {
772 if candidate.is_file() && is_rust_file(&candidate) {
773 files.insert(candidate);
774 } else if candidate.is_dir() {
775 collect_rust_files_in_dir(&candidate, files);
776 }
777 return Ok(());
778 }
779
780 let escaped_root = Pattern::escape(&root.to_string_lossy());
781 let normalized_pattern = entry.replace('\\', "/");
782 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
783 let matches = glob(&full_pattern).map_err(|err| {
784 io::Error::new(
785 io::ErrorKind::InvalidInput,
786 format!("invalid include pattern `{entry}`: {err}"),
787 )
788 })?;
789
790 for matched in matches {
791 let path = matched
792 .map_err(|err| io::Error::other(format!("failed to expand `{entry}`: {err}")))?;
793 if path.is_file() && is_rust_file(&path) {
794 files.insert(path);
795 } else if path.is_dir() {
796 collect_rust_files_in_dir(&path, files);
797 }
798 }
799
800 Ok(())
801}
802
803fn is_excluded_path(root: &Path, path: &Path, exclude_globs: &[String]) -> io::Result<bool> {
804 if exclude_globs.is_empty() {
805 return Ok(false);
806 }
807
808 let relative = path
809 .strip_prefix(root)
810 .unwrap_or(path)
811 .to_string_lossy()
812 .replace('\\', "/");
813 for pattern in exclude_globs {
814 if contains_glob_meta(pattern) {
815 let matcher = Pattern::new(pattern).map_err(|err| {
816 io::Error::new(
817 io::ErrorKind::InvalidInput,
818 format!("invalid exclude pattern `{pattern}`: {err}"),
819 )
820 })?;
821 if matcher.matches(&relative) {
822 return Ok(true);
823 }
824 continue;
825 }
826
827 let normalized = pattern.trim_end_matches('/').replace('\\', "/");
828 if relative == normalized || relative.starts_with(&format!("{normalized}/")) {
829 return Ok(true);
830 }
831 }
832 Ok(false)
833}
834
835fn collect_default_scan_roots(root: &Path) -> io::Result<Vec<PathBuf>> {
836 let mut scan_roots = BTreeSet::new();
837 let manifest_path = root.join("Cargo.toml");
838
839 if !manifest_path.is_file() {
840 add_src_root(root, &mut scan_roots);
841 return Ok(scan_roots.into_iter().collect());
842 }
843
844 let manifest_src = fs::read_to_string(&manifest_path)?;
845 let manifest: toml::Value = toml::from_str(&manifest_src).map_err(|err| {
846 io::Error::new(
847 io::ErrorKind::InvalidData,
848 format!("failed to parse {}: {err}", manifest_path.display()),
849 )
850 })?;
851
852 let root_is_package = manifest.get("package").is_some_and(toml::Value::is_table);
853 if root_is_package {
854 add_src_root(root, &mut scan_roots);
855 }
856
857 if let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) {
858 let excluded = parse_workspace_patterns(workspace.get("exclude"));
859 for member_pattern in parse_workspace_patterns(workspace.get("members")) {
860 for member_root in resolve_workspace_member_pattern(root, &member_pattern)? {
861 if is_excluded_member(root, &member_root, &excluded)? {
862 continue;
863 }
864 add_src_root(&member_root, &mut scan_roots);
865 }
866 }
867 } else if !root_is_package {
868 add_src_root(root, &mut scan_roots);
869 }
870
871 Ok(scan_roots.into_iter().collect())
872}
873
874fn parse_workspace_patterns(value: Option<&toml::Value>) -> Vec<String> {
875 value
876 .and_then(toml::Value::as_array)
877 .into_iter()
878 .flatten()
879 .filter_map(toml::Value::as_str)
880 .map(std::string::ToString::to_string)
881 .collect()
882}
883
884fn resolve_workspace_member_pattern(root: &Path, pattern: &str) -> io::Result<Vec<PathBuf>> {
885 let candidate = root.join(pattern);
886 if !contains_glob_meta(pattern) {
887 if candidate.is_dir() {
888 return Ok(vec![candidate]);
889 }
890 if candidate
891 .file_name()
892 .is_some_and(|name| name == "Cargo.toml")
893 && let Some(parent) = candidate.parent()
894 {
895 return Ok(vec![parent.to_path_buf()]);
896 }
897 return Ok(Vec::new());
898 }
899
900 let escaped_root = Pattern::escape(&root.to_string_lossy());
901 let normalized_pattern = pattern.replace('\\', "/");
902 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
903 let mut paths = Vec::new();
904 let matches = glob(&full_pattern).map_err(|err| {
905 io::Error::new(
906 io::ErrorKind::InvalidInput,
907 format!("invalid workspace member pattern `{pattern}`: {err}"),
908 )
909 })?;
910
911 for entry in matches {
912 let path = entry
913 .map_err(|err| io::Error::other(format!("failed to expand `{pattern}`: {err}")))?;
914 if path.is_dir() {
915 paths.push(path);
916 continue;
917 }
918 if path.file_name().is_some_and(|name| name == "Cargo.toml")
919 && let Some(parent) = path.parent()
920 {
921 paths.push(parent.to_path_buf());
922 }
923 }
924
925 Ok(paths)
926}
927
928fn contains_glob_meta(pattern: &str) -> bool {
929 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
930}
931
932fn is_excluded_member(root: &Path, member_root: &Path, excluded: &[String]) -> io::Result<bool> {
933 let relative = member_root
934 .strip_prefix(root)
935 .unwrap_or(member_root)
936 .to_string_lossy()
937 .replace('\\', "/");
938 for pattern in excluded {
939 let matcher = Pattern::new(pattern).map_err(|err| {
940 io::Error::new(
941 io::ErrorKind::InvalidInput,
942 format!("invalid workspace exclude pattern `{pattern}`: {err}"),
943 )
944 })?;
945 if matcher.matches(&relative) {
946 return Ok(true);
947 }
948 }
949 Ok(false)
950}
951
952fn add_src_root(root: &Path, scan_roots: &mut BTreeSet<PathBuf>) {
953 let src = root.join("src");
954 if src.is_dir() {
955 scan_roots.insert(src);
956 }
957}
958
959fn collect_rust_files_in_dir(dir: &Path, files: &mut BTreeSet<PathBuf>) {
960 for entry in WalkDir::new(dir)
961 .into_iter()
962 .filter_map(Result::ok)
963 .filter(|entry| entry.file_type().is_file())
964 {
965 let path = entry.path();
966 if is_rust_file(path) {
967 files.insert(path.to_path_buf());
968 }
969 }
970}
971
972fn is_rust_file(path: &Path) -> bool {
973 path.extension().is_some_and(|ext| ext == "rs")
974}
975
976pub(crate) fn is_public(vis: &syn::Visibility) -> bool {
977 !matches!(vis, syn::Visibility::Inherited)
978}
979
980pub(crate) fn unraw_ident(ident: &syn::Ident) -> String {
981 let text = ident.to_string();
982 text.strip_prefix("r#").unwrap_or(&text).to_string()
983}
984
985pub(crate) fn split_segments(name: &str) -> Vec<String> {
986 if name.contains('_') {
987 return name
988 .split('_')
989 .filter(|segment| !segment.is_empty())
990 .map(std::string::ToString::to_string)
991 .collect();
992 }
993
994 let chars: Vec<(usize, char)> = name.char_indices().collect();
995 if chars.is_empty() {
996 return Vec::new();
997 }
998
999 let mut starts = vec![0usize];
1000
1001 for i in 1..chars.len() {
1002 let prev = chars[i - 1].1;
1003 let curr = chars[i].1;
1004 let next = chars.get(i + 1).map(|(_, c)| *c);
1005
1006 let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
1007 let acronym_to_word = prev.is_ascii_uppercase()
1008 && curr.is_ascii_uppercase()
1009 && next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
1010
1011 if lower_to_upper || acronym_to_word {
1012 starts.push(chars[i].0);
1013 }
1014 }
1015
1016 let mut out = Vec::with_capacity(starts.len());
1017 for (idx, start) in starts.iter().enumerate() {
1018 let end = if let Some(next) = starts.get(idx + 1) {
1019 *next
1020 } else {
1021 name.len()
1022 };
1023 let seg = &name[*start..end];
1024 if !seg.is_empty() {
1025 out.push(seg.to_string());
1026 }
1027 }
1028
1029 out
1030}
1031
1032pub(crate) fn normalize_segment(segment: &str) -> String {
1033 segment.to_ascii_lowercase()
1034}
1035
1036#[derive(Clone, Copy)]
1037pub(crate) enum NameStyle {
1038 Pascal,
1039 Snake,
1040 ScreamingSnake,
1041}
1042
1043pub(crate) fn detect_name_style(name: &str) -> NameStyle {
1044 if name.contains('_') {
1045 if name
1046 .chars()
1047 .filter(|ch| ch.is_ascii_alphabetic())
1048 .all(|ch| ch.is_ascii_uppercase())
1049 {
1050 NameStyle::ScreamingSnake
1051 } else {
1052 NameStyle::Snake
1053 }
1054 } else {
1055 NameStyle::Pascal
1056 }
1057}
1058
1059pub(crate) fn render_segments(segments: &[String], style: NameStyle) -> String {
1060 match style {
1061 NameStyle::Pascal => segments
1062 .iter()
1063 .map(|segment| {
1064 let lower = segment.to_ascii_lowercase();
1065 let mut chars = lower.chars();
1066 let Some(first) = chars.next() else {
1067 return String::new();
1068 };
1069 let mut rendered = String::new();
1070 rendered.push(first.to_ascii_uppercase());
1071 rendered.extend(chars);
1072 rendered
1073 })
1074 .collect::<Vec<_>>()
1075 .join(""),
1076 NameStyle::Snake => segments
1077 .iter()
1078 .map(|segment| segment.to_ascii_lowercase())
1079 .collect::<Vec<_>>()
1080 .join("_"),
1081 NameStyle::ScreamingSnake => segments
1082 .iter()
1083 .map(|segment| segment.to_ascii_uppercase())
1084 .collect::<Vec<_>>()
1085 .join("_"),
1086 }
1087}
1088
1089pub(crate) fn inferred_file_module_path(path: &Path) -> Vec<String> {
1090 let components = path
1091 .iter()
1092 .map(|component| component.to_string_lossy().to_string())
1093 .collect::<Vec<_>>();
1094 let rel = if let Some(src_idx) = components.iter().rposition(|component| component == "src") {
1095 &components[src_idx + 1..]
1096 } else {
1097 &components[..]
1098 };
1099
1100 if rel.is_empty() || rel.first().is_some_and(|component| component == "bin") {
1101 return Vec::new();
1102 }
1103
1104 let mut module_path = Vec::new();
1105 for (idx, component) in rel.iter().enumerate() {
1106 let is_last = idx + 1 == rel.len();
1107 if is_last {
1108 match component.as_str() {
1109 "lib.rs" | "main.rs" | "mod.rs" => {}
1110 other => {
1111 if let Some(stem) = other.strip_suffix(".rs") {
1112 module_path.push(stem.to_string());
1113 }
1114 }
1115 }
1116 continue;
1117 }
1118
1119 module_path.push(component.to_string());
1120 }
1121
1122 module_path
1123}
1124
1125pub(crate) fn source_root(path: &Path) -> Option<PathBuf> {
1126 let mut root = PathBuf::new();
1127 for component in path.components() {
1128 root.push(component.as_os_str());
1129 if component.as_os_str() == "src" {
1130 return Some(root);
1131 }
1132 }
1133 None
1134}
1135
1136pub(crate) fn parent_module_files(src_root: &Path, prefix: &[String]) -> Vec<PathBuf> {
1137 if prefix.is_empty() {
1138 return vec![src_root.join("lib.rs"), src_root.join("main.rs")];
1139 }
1140
1141 let joined = prefix.join("/");
1142 vec![
1143 src_root.join(format!("{joined}.rs")),
1144 src_root.join(joined).join("mod.rs"),
1145 ]
1146}
1147
1148pub(crate) fn replace_path_fix(replacement: impl Into<String>) -> DiagnosticFix {
1149 DiagnosticFix {
1150 kind: DiagnosticFixKind::ReplacePath,
1151 replacement: replacement.into(),
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::{
1158 CheckMode, Diagnostic, DiagnosticSelection, NamespaceSettings, WorkspaceReport,
1159 check_exit_code, parse_check_mode, split_segments,
1160 };
1161
1162 #[test]
1163 fn splits_pascal_camel_snake_and_acronyms() {
1164 assert_eq!(split_segments("WhatEver"), vec!["What", "Ever"]);
1165 assert_eq!(split_segments("whatEver"), vec!["what", "Ever"]);
1166 assert_eq!(split_segments("what_ever"), vec!["what", "ever"]);
1167 assert_eq!(split_segments("HTTPServer"), vec!["HTTP", "Server"]);
1168 }
1169
1170 #[test]
1171 fn parses_check_modes() {
1172 assert_eq!(parse_check_mode("off"), Ok(CheckMode::Off));
1173 assert_eq!(parse_check_mode("warn"), Ok(CheckMode::Warn));
1174 assert_eq!(parse_check_mode("deny"), Ok(CheckMode::Deny));
1175 }
1176
1177 #[test]
1178 fn check_mode_supports_standard_parsing() {
1179 assert_eq!("off".parse::<CheckMode>(), Ok(CheckMode::Off));
1180 assert_eq!("warn".parse::<CheckMode>(), Ok(CheckMode::Warn));
1181 assert_eq!("deny".parse::<CheckMode>(), Ok(CheckMode::Deny));
1182 }
1183
1184 #[test]
1185 fn rejects_invalid_check_mode() {
1186 let err = parse_check_mode("strict").unwrap_err();
1187 assert!(err.contains("expected off|warn|deny"));
1188 }
1189
1190 #[test]
1191 fn diagnostic_selection_supports_standard_parsing() {
1192 assert_eq!(
1193 "all".parse::<DiagnosticSelection>(),
1194 Ok(DiagnosticSelection::All)
1195 );
1196 assert_eq!(
1197 "policy".parse::<DiagnosticSelection>(),
1198 Ok(DiagnosticSelection::Policy)
1199 );
1200 assert_eq!(
1201 "advisory".parse::<DiagnosticSelection>(),
1202 Ok(DiagnosticSelection::Advisory)
1203 );
1204 }
1205
1206 #[test]
1207 fn rejects_invalid_diagnostic_selection() {
1208 let err = "warnings".parse::<DiagnosticSelection>().unwrap_err();
1209 assert!(err.contains("expected all|policy|advisory"));
1210 }
1211
1212 #[test]
1213 fn check_exit_code_follows_warn_and_deny_semantics() {
1214 let clean = WorkspaceReport {
1215 scanned_files: 1,
1216 files_with_violations: 0,
1217 diagnostics: Vec::new(),
1218 };
1219 assert_eq!(check_exit_code(&clean, CheckMode::Warn), 0);
1220 assert_eq!(check_exit_code(&clean, CheckMode::Deny), 0);
1221
1222 let with_policy = WorkspaceReport {
1223 scanned_files: 1,
1224 files_with_violations: 1,
1225 diagnostics: vec![Diagnostic::policy(None, None, "lint", "warning")],
1226 };
1227 assert_eq!(check_exit_code(&with_policy, CheckMode::Warn), 0);
1228 assert_eq!(check_exit_code(&with_policy, CheckMode::Deny), 2);
1229
1230 let with_error = WorkspaceReport {
1231 scanned_files: 1,
1232 files_with_violations: 1,
1233 diagnostics: vec![Diagnostic::error(None, None, "error")],
1234 };
1235 assert_eq!(check_exit_code(&with_error, CheckMode::Warn), 1);
1236 assert_eq!(check_exit_code(&with_error, CheckMode::Deny), 1);
1237 }
1238
1239 #[test]
1240 fn namespace_settings_defaults_cover_generic_nouns_and_weak_modules() {
1241 let settings = NamespaceSettings::default();
1242 assert!(settings.generic_nouns.contains("Repository"));
1243 assert!(settings.generic_nouns.contains("Id"));
1244 assert!(settings.generic_nouns.contains("Outcome"));
1245 assert!(settings.weak_modules.contains("storage"));
1246 assert!(settings.catch_all_modules.contains("helpers"));
1247 assert!(settings.organizational_modules.contains("error"));
1248 assert!(settings.organizational_modules.contains("request"));
1249 assert!(settings.organizational_modules.contains("response"));
1250 assert!(settings.namespace_preserving_modules.contains("email"));
1251 assert!(settings.namespace_preserving_modules.contains("components"));
1252 assert!(settings.namespace_preserving_modules.contains("partials"));
1253 assert!(settings.namespace_preserving_modules.contains("trace"));
1254 assert!(settings.namespace_preserving_modules.contains("write_back"));
1255 assert!(!settings.namespace_preserving_modules.contains("views"));
1256 assert!(!settings.namespace_preserving_modules.contains("handlers"));
1257 }
1258
1259 #[test]
1260 fn workspace_report_can_filter_policy_and_advisory_diagnostics() {
1261 let report = WorkspaceReport {
1262 scanned_files: 2,
1263 files_with_violations: 2,
1264 diagnostics: vec![
1265 Diagnostic::policy(Some("src/policy.rs".into()), Some(1), "policy", "policy"),
1266 Diagnostic::advisory(
1267 Some("src/advisory.rs".into()),
1268 Some(2),
1269 "advisory",
1270 "advisory",
1271 ),
1272 Diagnostic::error(Some("src/error.rs".into()), Some(3), "error"),
1273 ],
1274 };
1275
1276 let policy_only = report.filtered(DiagnosticSelection::Policy);
1277 assert_eq!(policy_only.files_with_violations, 2);
1278 assert_eq!(policy_only.error_count(), 1);
1279 assert_eq!(policy_only.policy_warning_count(), 1);
1280 assert_eq!(policy_only.advisory_warning_count(), 0);
1281
1282 let advisory_only = report.filtered(DiagnosticSelection::Advisory);
1283 assert_eq!(advisory_only.files_with_violations, 2);
1284 assert_eq!(advisory_only.error_count(), 1);
1285 assert_eq!(advisory_only.policy_warning_count(), 0);
1286 assert_eq!(advisory_only.advisory_warning_count(), 1);
1287 }
1288}