Skip to main content

modum_core/
lib.rs

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}