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(
366 root,
367 file,
368 &workspace_defaults,
369 &mut package_cache,
370 &mut diagnostics,
371 );
372 let analysis = analyze_file_with_settings(file, &src, &settings);
373 if !analysis.diagnostics.is_empty() {
374 files_with_violations.insert(file.clone());
375 }
376 diagnostics.extend(analysis.diagnostics);
377 }
378
379 diagnostics.sort();
380
381 WorkspaceReport {
382 scanned_files: rust_files.len(),
383 files_with_violations: files_with_violations.len(),
384 diagnostics,
385 }
386}
387
388fn effective_scan_settings(
389 repo_defaults: &ScanSettings,
390 cli_overrides: &ScanSettings,
391) -> ScanSettings {
392 let include = if cli_overrides.include.is_empty() {
393 repo_defaults.include.clone()
394 } else {
395 cli_overrides.include.clone()
396 };
397 let mut exclude = repo_defaults.exclude.clone();
398 exclude.extend(cli_overrides.exclude.iter().cloned());
399 ScanSettings { include, exclude }
400}
401
402fn load_workspace_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> NamespaceSettings {
403 let manifest_path = root.join("Cargo.toml");
404 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
405 return NamespaceSettings::default();
406 };
407
408 let manifest: toml::Value = match toml::from_str(&manifest_src) {
409 Ok(manifest) => manifest,
410 Err(err) => {
411 diagnostics.push(Diagnostic::error(
412 Some(manifest_path),
413 None,
414 format!("failed to parse Cargo.toml for modum settings: {err}"),
415 ));
416 return NamespaceSettings::default();
417 }
418 };
419
420 parse_settings_from_manifest(
421 manifest
422 .get("workspace")
423 .and_then(toml::Value::as_table)
424 .and_then(|workspace| workspace.get("metadata"))
425 .and_then(toml::Value::as_table)
426 .and_then(|metadata| metadata.get("modum")),
427 &manifest_path,
428 diagnostics,
429 )
430 .unwrap_or_default()
431}
432
433fn load_repo_scan_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> ScanSettings {
434 let manifest_path = root.join("Cargo.toml");
435 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
436 return ScanSettings::default();
437 };
438
439 let manifest: toml::Value = match toml::from_str(&manifest_src) {
440 Ok(manifest) => manifest,
441 Err(err) => {
442 diagnostics.push(Diagnostic::error(
443 Some(manifest_path),
444 None,
445 format!("failed to parse Cargo.toml for modum settings: {err}"),
446 ));
447 return ScanSettings::default();
448 }
449 };
450
451 parse_scan_settings_from_manifest(
452 manifest
453 .get("workspace")
454 .and_then(toml::Value::as_table)
455 .and_then(|workspace| workspace.get("metadata"))
456 .and_then(toml::Value::as_table)
457 .and_then(|metadata| metadata.get("modum"))
458 .or_else(|| {
459 manifest
460 .get("package")
461 .and_then(toml::Value::as_table)
462 .and_then(|package| package.get("metadata"))
463 .and_then(toml::Value::as_table)
464 .and_then(|metadata| metadata.get("modum"))
465 }),
466 &manifest_path,
467 diagnostics,
468 )
469 .unwrap_or_default()
470}
471
472fn settings_for_file(
473 root: &Path,
474 file: &Path,
475 workspace_defaults: &NamespaceSettings,
476 cache: &mut BTreeMap<PathBuf, NamespaceSettings>,
477 diagnostics: &mut Vec<Diagnostic>,
478) -> NamespaceSettings {
479 let Some(package_root) = find_package_root(root, file) else {
480 return workspace_defaults.clone();
481 };
482
483 if let Some(settings) = cache.get(&package_root) {
484 return settings.clone();
485 }
486
487 let settings = load_package_settings(&package_root, workspace_defaults, diagnostics);
488 cache.insert(package_root, settings.clone());
489 settings
490}
491
492fn load_package_settings(
493 root: &Path,
494 workspace_defaults: &NamespaceSettings,
495 diagnostics: &mut Vec<Diagnostic>,
496) -> NamespaceSettings {
497 let manifest_path = root.join("Cargo.toml");
498 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
499 return workspace_defaults.clone();
500 };
501
502 let manifest = match toml::from_str::<toml::Value>(&manifest_src) {
503 Ok(manifest) => manifest,
504 Err(err) => {
505 diagnostics.push(Diagnostic::error(
506 Some(manifest_path),
507 None,
508 format!("failed to parse Cargo.toml for modum settings: {err}"),
509 ));
510 return workspace_defaults.clone();
511 }
512 };
513
514 parse_settings_from_manifest(
515 manifest
516 .get("package")
517 .and_then(toml::Value::as_table)
518 .and_then(|package| package.get("metadata"))
519 .and_then(toml::Value::as_table)
520 .and_then(|metadata| metadata.get("modum")),
521 &manifest_path,
522 diagnostics,
523 )
524 .unwrap_or_else(|| workspace_defaults.clone())
525}
526
527fn parse_settings_from_manifest(
528 value: Option<&toml::Value>,
529 manifest_path: &Path,
530 diagnostics: &mut Vec<Diagnostic>,
531) -> Option<NamespaceSettings> {
532 let table = value?.as_table()?;
533 let mut settings = NamespaceSettings::default();
534
535 if let Some(values) = parse_string_set_field(table, "generic_nouns", manifest_path, diagnostics)
536 {
537 settings.generic_nouns = values;
538 }
539 if let Some(values) = parse_string_set_field(table, "weak_modules", manifest_path, diagnostics)
540 {
541 settings.weak_modules = values;
542 }
543 if let Some(values) =
544 parse_string_set_field(table, "catch_all_modules", manifest_path, diagnostics)
545 {
546 settings.catch_all_modules = values;
547 }
548 if let Some(values) =
549 parse_string_set_field(table, "organizational_modules", manifest_path, diagnostics)
550 {
551 settings.organizational_modules = values;
552 }
553 if let Some(values) = parse_string_set_field(
554 table,
555 "namespace_preserving_modules",
556 manifest_path,
557 diagnostics,
558 ) {
559 settings.namespace_preserving_modules = values;
560 }
561
562 Some(settings)
563}
564
565fn parse_scan_settings_from_manifest(
566 value: Option<&toml::Value>,
567 manifest_path: &Path,
568 diagnostics: &mut Vec<Diagnostic>,
569) -> Option<ScanSettings> {
570 let table = value?.as_table()?;
571 let mut settings = ScanSettings::default();
572
573 if let Some(values) = parse_string_list_field(table, "include", manifest_path, diagnostics) {
574 settings.include = values;
575 }
576 if let Some(values) = parse_string_list_field(table, "exclude", manifest_path, diagnostics) {
577 settings.exclude = values;
578 }
579
580 Some(settings)
581}
582
583fn parse_string_set_field(
584 table: &toml::value::Table,
585 key: &str,
586 manifest_path: &Path,
587 diagnostics: &mut Vec<Diagnostic>,
588) -> Option<BTreeSet<String>> {
589 Some(
590 parse_string_values_field(table, key, manifest_path, diagnostics)?
591 .into_iter()
592 .collect(),
593 )
594}
595
596fn parse_string_list_field(
597 table: &toml::value::Table,
598 key: &str,
599 manifest_path: &Path,
600 diagnostics: &mut Vec<Diagnostic>,
601) -> Option<Vec<String>> {
602 parse_string_values_field(table, key, manifest_path, diagnostics)
603}
604
605fn parse_string_values_field(
606 table: &toml::value::Table,
607 key: &str,
608 manifest_path: &Path,
609 diagnostics: &mut Vec<Diagnostic>,
610) -> Option<Vec<String>> {
611 let value = table.get(key)?;
612 let Some(array) = value.as_array() else {
613 diagnostics.push(Diagnostic::error(
614 Some(manifest_path.to_path_buf()),
615 None,
616 format!("`metadata.modum.{key}` must be an array of strings"),
617 ));
618 return None;
619 };
620
621 let mut values = Vec::with_capacity(array.len());
622 for (index, value) in array.iter().enumerate() {
623 let Some(value) = value.as_str() else {
624 diagnostics.push(Diagnostic::error(
625 Some(manifest_path.to_path_buf()),
626 None,
627 format!("`metadata.modum.{key}[{index}]` must be a string"),
628 ));
629 return None;
630 };
631 values.push(value.to_string());
632 }
633
634 Some(values)
635}
636
637fn find_package_root(root: &Path, file: &Path) -> Option<PathBuf> {
638 for ancestor in file.ancestors().skip(1) {
639 let manifest_path = ancestor.join("Cargo.toml");
640 if manifest_path.is_file()
641 && let Ok(manifest_src) = fs::read_to_string(&manifest_path)
642 && let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src)
643 && manifest.get("package").is_some_and(toml::Value::is_table)
644 {
645 return Some(ancestor.to_path_buf());
646 }
647 if ancestor == root {
648 break;
649 }
650 }
651 None
652}
653
654pub fn render_pretty_report(report: &WorkspaceReport) -> String {
655 render_pretty_report_with_selection(report, DiagnosticSelection::All)
656}
657
658pub fn render_pretty_report_with_selection(
659 report: &WorkspaceReport,
660 selection: DiagnosticSelection,
661) -> String {
662 let filtered = report.filtered(selection);
663 let mut out = String::new();
664
665 let _ = writeln!(&mut out, "modum lint report");
666 let _ = writeln!(&mut out, "files scanned: {}", filtered.scanned_files);
667 let _ = writeln!(
668 &mut out,
669 "files with violations: {}",
670 filtered.files_with_violations
671 );
672 let _ = writeln!(
673 &mut out,
674 "diagnostics: {} error(s), {} policy warning(s), {} advisory warning(s)",
675 filtered.error_count(),
676 filtered.policy_warning_count(),
677 filtered.advisory_warning_count()
678 );
679 if let Some(selection_label) = selection.report_label() {
680 let _ = writeln!(
681 &mut out,
682 "showing: {selection_label} (exit code still reflects the full report)"
683 );
684 }
685 if filtered.policy_violation_count() > 0 {
686 let _ = writeln!(
687 &mut out,
688 "policy violations: {}",
689 filtered.policy_violation_count()
690 );
691 }
692 if filtered.advisory_warning_count() > 0 {
693 let _ = writeln!(
694 &mut out,
695 "advisories: {}",
696 filtered.advisory_warning_count()
697 );
698 }
699
700 if !filtered.diagnostics.is_empty() {
701 let _ = writeln!(&mut out);
702 render_diagnostic_section(
703 &mut out,
704 "Errors:",
705 filtered.diagnostics.iter().filter(|diag| diag.is_error()),
706 );
707 render_diagnostic_section(
708 &mut out,
709 "Policy Diagnostics:",
710 filtered
711 .diagnostics
712 .iter()
713 .filter(|diag| diag.is_policy_warning()),
714 );
715 render_diagnostic_section(
716 &mut out,
717 "Advisory Diagnostics:",
718 filtered
719 .diagnostics
720 .iter()
721 .filter(|diag| diag.is_advisory_warning()),
722 );
723 }
724
725 out
726}
727
728fn render_diagnostic_section<'a>(
729 out: &mut String,
730 title: &str,
731 diagnostics: impl Iterator<Item = &'a Diagnostic>,
732) {
733 let diagnostics = diagnostics.collect::<Vec<_>>();
734 if diagnostics.is_empty() {
735 return;
736 }
737
738 let _ = writeln!(out, "{title}");
739 for diag in diagnostics {
740 let level = match diag.level() {
741 DiagnosticLevel::Warning => "warning",
742 DiagnosticLevel::Error => "error",
743 };
744 let code = diag
745 .code()
746 .map(|code| format!(" ({code})"))
747 .unwrap_or_default();
748 match (&diag.file, diag.line) {
749 (Some(file), Some(line)) => {
750 let _ = writeln!(
751 out,
752 "- [{level}{code}] {}:{line}: {}",
753 file.display(),
754 diag.message
755 );
756 }
757 (Some(file), None) => {
758 let _ = writeln!(
759 out,
760 "- [{level}{code}] {}: {}",
761 file.display(),
762 diag.message
763 );
764 }
765 (None, _) => {
766 let _ = writeln!(out, "- [{level}{code}] {}", diag.message);
767 }
768 }
769 }
770 let _ = writeln!(out);
771}
772
773fn collect_rust_files(
774 root: &Path,
775 include_globs: &[String],
776 exclude_globs: &[String],
777) -> io::Result<Vec<PathBuf>> {
778 let mut files = BTreeSet::new();
779 if include_globs.is_empty() {
780 for scan_root in collect_default_scan_roots(root)? {
781 collect_rust_files_in_dir(&scan_root, &mut files);
782 }
783 } else {
784 for entry in include_globs {
785 collect_rust_files_for_entry(root, entry, &mut files)?;
786 }
787 }
788
789 let mut filtered = Vec::with_capacity(files.len());
790 for path in files {
791 if !is_excluded_path(root, &path, exclude_globs)? {
792 filtered.push(path);
793 }
794 }
795
796 Ok(filtered)
797}
798
799fn collect_rust_files_for_entry(
800 root: &Path,
801 entry: &str,
802 files: &mut BTreeSet<PathBuf>,
803) -> io::Result<()> {
804 let candidate = root.join(entry);
805 if !contains_glob_meta(entry) {
806 if candidate.is_file() && is_rust_file(&candidate) {
807 files.insert(candidate);
808 } else if candidate.is_dir() {
809 collect_rust_files_in_dir(&candidate, files);
810 }
811 return Ok(());
812 }
813
814 let escaped_root = Pattern::escape(&root.to_string_lossy());
815 let normalized_pattern = entry.replace('\\', "/");
816 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
817 let matches = glob(&full_pattern).map_err(|err| {
818 io::Error::new(
819 io::ErrorKind::InvalidInput,
820 format!("invalid include pattern `{entry}`: {err}"),
821 )
822 })?;
823
824 for matched in matches {
825 let path = matched
826 .map_err(|err| io::Error::other(format!("failed to expand `{entry}`: {err}")))?;
827 if path.is_file() && is_rust_file(&path) {
828 files.insert(path);
829 } else if path.is_dir() {
830 collect_rust_files_in_dir(&path, files);
831 }
832 }
833
834 Ok(())
835}
836
837fn is_excluded_path(root: &Path, path: &Path, exclude_globs: &[String]) -> io::Result<bool> {
838 if exclude_globs.is_empty() {
839 return Ok(false);
840 }
841
842 let relative = path
843 .strip_prefix(root)
844 .unwrap_or(path)
845 .to_string_lossy()
846 .replace('\\', "/");
847 for pattern in exclude_globs {
848 if contains_glob_meta(pattern) {
849 let matcher = Pattern::new(pattern).map_err(|err| {
850 io::Error::new(
851 io::ErrorKind::InvalidInput,
852 format!("invalid exclude pattern `{pattern}`: {err}"),
853 )
854 })?;
855 if matcher.matches(&relative) {
856 return Ok(true);
857 }
858 continue;
859 }
860
861 let normalized = pattern.trim_end_matches('/').replace('\\', "/");
862 if relative == normalized || relative.starts_with(&format!("{normalized}/")) {
863 return Ok(true);
864 }
865 }
866 Ok(false)
867}
868
869fn collect_default_scan_roots(root: &Path) -> io::Result<Vec<PathBuf>> {
870 let mut scan_roots = BTreeSet::new();
871 let manifest_path = root.join("Cargo.toml");
872
873 if !manifest_path.is_file() {
874 add_src_root(root, &mut scan_roots);
875 return Ok(scan_roots.into_iter().collect());
876 }
877
878 let manifest_src = fs::read_to_string(&manifest_path)?;
879 let manifest: toml::Value = toml::from_str(&manifest_src).map_err(|err| {
880 io::Error::new(
881 io::ErrorKind::InvalidData,
882 format!("failed to parse {}: {err}", manifest_path.display()),
883 )
884 })?;
885
886 let root_is_package = manifest.get("package").is_some_and(toml::Value::is_table);
887 if root_is_package {
888 add_src_root(root, &mut scan_roots);
889 }
890
891 if let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) {
892 let excluded = parse_workspace_patterns(workspace.get("exclude"));
893 for member_pattern in parse_workspace_patterns(workspace.get("members")) {
894 for member_root in resolve_workspace_member_pattern(root, &member_pattern)? {
895 if is_excluded_member(root, &member_root, &excluded)? {
896 continue;
897 }
898 add_src_root(&member_root, &mut scan_roots);
899 }
900 }
901 } else if !root_is_package {
902 add_src_root(root, &mut scan_roots);
903 }
904
905 Ok(scan_roots.into_iter().collect())
906}
907
908fn parse_workspace_patterns(value: Option<&toml::Value>) -> Vec<String> {
909 value
910 .and_then(toml::Value::as_array)
911 .into_iter()
912 .flatten()
913 .filter_map(toml::Value::as_str)
914 .map(std::string::ToString::to_string)
915 .collect()
916}
917
918fn resolve_workspace_member_pattern(root: &Path, pattern: &str) -> io::Result<Vec<PathBuf>> {
919 let candidate = root.join(pattern);
920 if !contains_glob_meta(pattern) {
921 if candidate.is_dir() {
922 return Ok(vec![candidate]);
923 }
924 if candidate
925 .file_name()
926 .is_some_and(|name| name == "Cargo.toml")
927 && let Some(parent) = candidate.parent()
928 {
929 return Ok(vec![parent.to_path_buf()]);
930 }
931 return Ok(Vec::new());
932 }
933
934 let escaped_root = Pattern::escape(&root.to_string_lossy());
935 let normalized_pattern = pattern.replace('\\', "/");
936 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
937 let mut paths = Vec::new();
938 let matches = glob(&full_pattern).map_err(|err| {
939 io::Error::new(
940 io::ErrorKind::InvalidInput,
941 format!("invalid workspace member pattern `{pattern}`: {err}"),
942 )
943 })?;
944
945 for entry in matches {
946 let path = entry
947 .map_err(|err| io::Error::other(format!("failed to expand `{pattern}`: {err}")))?;
948 if path.is_dir() {
949 paths.push(path);
950 continue;
951 }
952 if path.file_name().is_some_and(|name| name == "Cargo.toml")
953 && let Some(parent) = path.parent()
954 {
955 paths.push(parent.to_path_buf());
956 }
957 }
958
959 Ok(paths)
960}
961
962fn contains_glob_meta(pattern: &str) -> bool {
963 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
964}
965
966fn is_excluded_member(root: &Path, member_root: &Path, excluded: &[String]) -> io::Result<bool> {
967 let relative = member_root
968 .strip_prefix(root)
969 .unwrap_or(member_root)
970 .to_string_lossy()
971 .replace('\\', "/");
972 for pattern in excluded {
973 let matcher = Pattern::new(pattern).map_err(|err| {
974 io::Error::new(
975 io::ErrorKind::InvalidInput,
976 format!("invalid workspace exclude pattern `{pattern}`: {err}"),
977 )
978 })?;
979 if matcher.matches(&relative) {
980 return Ok(true);
981 }
982 }
983 Ok(false)
984}
985
986fn add_src_root(root: &Path, scan_roots: &mut BTreeSet<PathBuf>) {
987 let src = root.join("src");
988 if src.is_dir() {
989 scan_roots.insert(src);
990 }
991}
992
993fn collect_rust_files_in_dir(dir: &Path, files: &mut BTreeSet<PathBuf>) {
994 for entry in WalkDir::new(dir)
995 .into_iter()
996 .filter_map(Result::ok)
997 .filter(|entry| entry.file_type().is_file())
998 {
999 let path = entry.path();
1000 if is_rust_file(path) {
1001 files.insert(path.to_path_buf());
1002 }
1003 }
1004}
1005
1006fn is_rust_file(path: &Path) -> bool {
1007 path.extension().is_some_and(|ext| ext == "rs")
1008}
1009
1010pub(crate) fn is_public(vis: &syn::Visibility) -> bool {
1011 !matches!(vis, syn::Visibility::Inherited)
1012}
1013
1014pub(crate) fn unraw_ident(ident: &syn::Ident) -> String {
1015 let text = ident.to_string();
1016 text.strip_prefix("r#").unwrap_or(&text).to_string()
1017}
1018
1019pub(crate) fn split_segments(name: &str) -> Vec<String> {
1020 if name.contains('_') {
1021 return name
1022 .split('_')
1023 .filter(|segment| !segment.is_empty())
1024 .map(std::string::ToString::to_string)
1025 .collect();
1026 }
1027
1028 let chars: Vec<(usize, char)> = name.char_indices().collect();
1029 if chars.is_empty() {
1030 return Vec::new();
1031 }
1032
1033 let mut starts = vec![0usize];
1034
1035 for i in 1..chars.len() {
1036 let prev = chars[i - 1].1;
1037 let curr = chars[i].1;
1038 let next = chars.get(i + 1).map(|(_, c)| *c);
1039
1040 let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
1041 let acronym_to_word = prev.is_ascii_uppercase()
1042 && curr.is_ascii_uppercase()
1043 && next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
1044
1045 if lower_to_upper || acronym_to_word {
1046 starts.push(chars[i].0);
1047 }
1048 }
1049
1050 let mut out = Vec::with_capacity(starts.len());
1051 for (idx, start) in starts.iter().enumerate() {
1052 let end = if let Some(next) = starts.get(idx + 1) {
1053 *next
1054 } else {
1055 name.len()
1056 };
1057 let seg = &name[*start..end];
1058 if !seg.is_empty() {
1059 out.push(seg.to_string());
1060 }
1061 }
1062
1063 out
1064}
1065
1066pub(crate) fn normalize_segment(segment: &str) -> String {
1067 segment.to_ascii_lowercase()
1068}
1069
1070#[derive(Clone, Copy)]
1071pub(crate) enum NameStyle {
1072 Pascal,
1073 Snake,
1074 ScreamingSnake,
1075}
1076
1077pub(crate) fn detect_name_style(name: &str) -> NameStyle {
1078 if name.contains('_') {
1079 if name
1080 .chars()
1081 .filter(|ch| ch.is_ascii_alphabetic())
1082 .all(|ch| ch.is_ascii_uppercase())
1083 {
1084 NameStyle::ScreamingSnake
1085 } else {
1086 NameStyle::Snake
1087 }
1088 } else {
1089 NameStyle::Pascal
1090 }
1091}
1092
1093pub(crate) fn render_segments(segments: &[String], style: NameStyle) -> String {
1094 match style {
1095 NameStyle::Pascal => segments
1096 .iter()
1097 .map(|segment| {
1098 let lower = segment.to_ascii_lowercase();
1099 let mut chars = lower.chars();
1100 let Some(first) = chars.next() else {
1101 return String::new();
1102 };
1103 let mut rendered = String::new();
1104 rendered.push(first.to_ascii_uppercase());
1105 rendered.extend(chars);
1106 rendered
1107 })
1108 .collect::<Vec<_>>()
1109 .join(""),
1110 NameStyle::Snake => segments
1111 .iter()
1112 .map(|segment| segment.to_ascii_lowercase())
1113 .collect::<Vec<_>>()
1114 .join("_"),
1115 NameStyle::ScreamingSnake => segments
1116 .iter()
1117 .map(|segment| segment.to_ascii_uppercase())
1118 .collect::<Vec<_>>()
1119 .join("_"),
1120 }
1121}
1122
1123pub(crate) fn inferred_file_module_path(path: &Path) -> Vec<String> {
1124 let components = path
1125 .iter()
1126 .map(|component| component.to_string_lossy().to_string())
1127 .collect::<Vec<_>>();
1128 let rel = if let Some(src_idx) = components.iter().rposition(|component| component == "src") {
1129 &components[src_idx + 1..]
1130 } else {
1131 &components[..]
1132 };
1133
1134 if rel.is_empty() || rel.first().is_some_and(|component| component == "bin") {
1135 return Vec::new();
1136 }
1137
1138 let mut module_path = Vec::new();
1139 for (idx, component) in rel.iter().enumerate() {
1140 let is_last = idx + 1 == rel.len();
1141 if is_last {
1142 match component.as_str() {
1143 "lib.rs" | "main.rs" | "mod.rs" => {}
1144 other => {
1145 if let Some(stem) = other.strip_suffix(".rs") {
1146 module_path.push(stem.to_string());
1147 }
1148 }
1149 }
1150 continue;
1151 }
1152
1153 module_path.push(component.to_string());
1154 }
1155
1156 module_path
1157}
1158
1159pub(crate) fn source_root(path: &Path) -> Option<PathBuf> {
1160 let mut root = PathBuf::new();
1161 for component in path.components() {
1162 root.push(component.as_os_str());
1163 if component.as_os_str() == "src" {
1164 return Some(root);
1165 }
1166 }
1167 None
1168}
1169
1170pub(crate) fn parent_module_files(src_root: &Path, prefix: &[String]) -> Vec<PathBuf> {
1171 if prefix.is_empty() {
1172 return vec![src_root.join("lib.rs"), src_root.join("main.rs")];
1173 }
1174
1175 let joined = prefix.join("/");
1176 vec![
1177 src_root.join(format!("{joined}.rs")),
1178 src_root.join(joined).join("mod.rs"),
1179 ]
1180}
1181
1182pub(crate) fn replace_path_fix(replacement: impl Into<String>) -> DiagnosticFix {
1183 DiagnosticFix {
1184 kind: DiagnosticFixKind::ReplacePath,
1185 replacement: replacement.into(),
1186 }
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::{
1192 CheckMode, Diagnostic, DiagnosticSelection, NamespaceSettings, WorkspaceReport,
1193 check_exit_code, parse_check_mode, split_segments,
1194 };
1195
1196 #[test]
1197 fn splits_pascal_camel_snake_and_acronyms() {
1198 assert_eq!(split_segments("WhatEver"), vec!["What", "Ever"]);
1199 assert_eq!(split_segments("whatEver"), vec!["what", "Ever"]);
1200 assert_eq!(split_segments("what_ever"), vec!["what", "ever"]);
1201 assert_eq!(split_segments("HTTPServer"), vec!["HTTP", "Server"]);
1202 }
1203
1204 #[test]
1205 fn parses_check_modes() {
1206 assert_eq!(parse_check_mode("off"), Ok(CheckMode::Off));
1207 assert_eq!(parse_check_mode("warn"), Ok(CheckMode::Warn));
1208 assert_eq!(parse_check_mode("deny"), Ok(CheckMode::Deny));
1209 }
1210
1211 #[test]
1212 fn check_mode_supports_standard_parsing() {
1213 assert_eq!("off".parse::<CheckMode>(), Ok(CheckMode::Off));
1214 assert_eq!("warn".parse::<CheckMode>(), Ok(CheckMode::Warn));
1215 assert_eq!("deny".parse::<CheckMode>(), Ok(CheckMode::Deny));
1216 }
1217
1218 #[test]
1219 fn rejects_invalid_check_mode() {
1220 let err = parse_check_mode("strict").unwrap_err();
1221 assert!(err.contains("expected off|warn|deny"));
1222 }
1223
1224 #[test]
1225 fn diagnostic_selection_supports_standard_parsing() {
1226 assert_eq!(
1227 "all".parse::<DiagnosticSelection>(),
1228 Ok(DiagnosticSelection::All)
1229 );
1230 assert_eq!(
1231 "policy".parse::<DiagnosticSelection>(),
1232 Ok(DiagnosticSelection::Policy)
1233 );
1234 assert_eq!(
1235 "advisory".parse::<DiagnosticSelection>(),
1236 Ok(DiagnosticSelection::Advisory)
1237 );
1238 }
1239
1240 #[test]
1241 fn rejects_invalid_diagnostic_selection() {
1242 let err = "warnings".parse::<DiagnosticSelection>().unwrap_err();
1243 assert!(err.contains("expected all|policy|advisory"));
1244 }
1245
1246 #[test]
1247 fn check_exit_code_follows_warn_and_deny_semantics() {
1248 let clean = WorkspaceReport {
1249 scanned_files: 1,
1250 files_with_violations: 0,
1251 diagnostics: Vec::new(),
1252 };
1253 assert_eq!(check_exit_code(&clean, CheckMode::Warn), 0);
1254 assert_eq!(check_exit_code(&clean, CheckMode::Deny), 0);
1255
1256 let with_policy = WorkspaceReport {
1257 scanned_files: 1,
1258 files_with_violations: 1,
1259 diagnostics: vec![Diagnostic::policy(None, None, "lint", "warning")],
1260 };
1261 assert_eq!(check_exit_code(&with_policy, CheckMode::Warn), 0);
1262 assert_eq!(check_exit_code(&with_policy, CheckMode::Deny), 2);
1263
1264 let with_error = WorkspaceReport {
1265 scanned_files: 1,
1266 files_with_violations: 1,
1267 diagnostics: vec![Diagnostic::error(None, None, "error")],
1268 };
1269 assert_eq!(check_exit_code(&with_error, CheckMode::Warn), 1);
1270 assert_eq!(check_exit_code(&with_error, CheckMode::Deny), 1);
1271 }
1272
1273 #[test]
1274 fn namespace_settings_defaults_cover_generic_nouns_and_weak_modules() {
1275 let settings = NamespaceSettings::default();
1276 assert!(settings.generic_nouns.contains("Repository"));
1277 assert!(settings.generic_nouns.contains("Id"));
1278 assert!(settings.generic_nouns.contains("Outcome"));
1279 assert!(settings.weak_modules.contains("storage"));
1280 assert!(settings.catch_all_modules.contains("helpers"));
1281 assert!(settings.organizational_modules.contains("error"));
1282 assert!(settings.organizational_modules.contains("request"));
1283 assert!(settings.organizational_modules.contains("response"));
1284 assert!(settings.namespace_preserving_modules.contains("email"));
1285 assert!(settings.namespace_preserving_modules.contains("components"));
1286 assert!(settings.namespace_preserving_modules.contains("partials"));
1287 assert!(settings.namespace_preserving_modules.contains("trace"));
1288 assert!(settings.namespace_preserving_modules.contains("write_back"));
1289 assert!(!settings.namespace_preserving_modules.contains("views"));
1290 assert!(!settings.namespace_preserving_modules.contains("handlers"));
1291 }
1292
1293 #[test]
1294 fn workspace_report_can_filter_policy_and_advisory_diagnostics() {
1295 let report = WorkspaceReport {
1296 scanned_files: 2,
1297 files_with_violations: 2,
1298 diagnostics: vec![
1299 Diagnostic::policy(Some("src/policy.rs".into()), Some(1), "policy", "policy"),
1300 Diagnostic::advisory(
1301 Some("src/advisory.rs".into()),
1302 Some(2),
1303 "advisory",
1304 "advisory",
1305 ),
1306 Diagnostic::error(Some("src/error.rs".into()), Some(3), "error"),
1307 ],
1308 };
1309
1310 let policy_only = report.filtered(DiagnosticSelection::Policy);
1311 assert_eq!(policy_only.files_with_violations, 2);
1312 assert_eq!(policy_only.error_count(), 1);
1313 assert_eq!(policy_only.policy_warning_count(), 1);
1314 assert_eq!(policy_only.advisory_warning_count(), 0);
1315
1316 let advisory_only = report.filtered(DiagnosticSelection::Advisory);
1317 assert_eq!(advisory_only.files_with_violations, 2);
1318 assert_eq!(advisory_only.error_count(), 1);
1319 assert_eq!(advisory_only.policy_warning_count(), 0);
1320 assert_eq!(advisory_only.advisory_warning_count(), 1);
1321 }
1322}