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(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}