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 namespace;
14
15const DEFAULT_GENERIC_NOUNS: &[&str] = &[
16    "Id",
17    "Repository",
18    "Service",
19    "Error",
20    "Command",
21    "Request",
22    "Response",
23    "Outcome",
24];
25
26const DEFAULT_WEAK_MODULES: &[&str] = &[
27    "storage",
28    "transport",
29    "infra",
30    "common",
31    "misc",
32    "helpers",
33    "helper",
34    "types",
35    "util",
36    "utils",
37];
38
39const DEFAULT_CATCH_ALL_MODULES: &[&str] = &[
40    "common", "misc", "helpers", "helper", "types", "util", "utils",
41];
42
43const DEFAULT_ORGANIZATIONAL_MODULES: &[&str] = &["error", "errors", "request", "response"];
44
45const DEFAULT_NAMESPACE_PRESERVING_MODULES: &[&str] = &[
46    "auth",
47    "command",
48    "components",
49    "email",
50    "error",
51    "http",
52    "page",
53    "partials",
54    "policy",
55    "query",
56    "repo",
57    "store",
58    "storage",
59    "transport",
60    "infra",
61];
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
64pub enum DiagnosticLevel {
65    Warning,
66    Error,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
70pub struct Diagnostic {
71    pub level: DiagnosticLevel,
72    pub file: Option<PathBuf>,
73    pub line: Option<usize>,
74    pub code: Option<String>,
75    pub policy: bool,
76    pub message: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnalysisResult {
81    pub diagnostics: Vec<Diagnostic>,
82}
83
84impl AnalysisResult {
85    fn empty() -> Self {
86        Self {
87            diagnostics: Vec::new(),
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct WorkspaceReport {
94    pub scanned_files: usize,
95    pub files_with_violations: usize,
96    pub diagnostics: Vec<Diagnostic>,
97}
98
99impl WorkspaceReport {
100    pub fn error_count(&self) -> usize {
101        self.diagnostics
102            .iter()
103            .filter(|diag| diag.level == DiagnosticLevel::Error)
104            .count()
105    }
106
107    pub fn warning_count(&self) -> usize {
108        self.diagnostics
109            .iter()
110            .filter(|diag| diag.level == DiagnosticLevel::Warning)
111            .count()
112    }
113
114    pub fn policy_violation_count(&self) -> usize {
115        self.diagnostics.iter().filter(|diag| diag.policy).count()
116    }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum CheckMode {
121    Off,
122    Warn,
123    Deny,
124}
125
126impl CheckMode {
127    pub fn parse(raw: &str) -> Result<Self, String> {
128        match raw {
129            "off" => Ok(Self::Off),
130            "warn" => Ok(Self::Warn),
131            "deny" => Ok(Self::Deny),
132            _ => Err(format!("invalid mode `{raw}`; expected off|warn|deny")),
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct CheckOutcome {
139    pub report: WorkspaceReport,
140    pub exit_code: u8,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144struct NamespaceSettings {
145    generic_nouns: BTreeSet<String>,
146    weak_modules: BTreeSet<String>,
147    catch_all_modules: BTreeSet<String>,
148    organizational_modules: BTreeSet<String>,
149    namespace_preserving_modules: BTreeSet<String>,
150}
151
152impl Default for NamespaceSettings {
153    fn default() -> Self {
154        Self {
155            generic_nouns: DEFAULT_GENERIC_NOUNS
156                .iter()
157                .map(|noun| (*noun).to_string())
158                .collect(),
159            weak_modules: DEFAULT_WEAK_MODULES
160                .iter()
161                .map(|module| (*module).to_string())
162                .collect(),
163            catch_all_modules: DEFAULT_CATCH_ALL_MODULES
164                .iter()
165                .map(|module| (*module).to_string())
166                .collect(),
167            organizational_modules: DEFAULT_ORGANIZATIONAL_MODULES
168                .iter()
169                .map(|module| (*module).to_string())
170                .collect(),
171            namespace_preserving_modules: DEFAULT_NAMESPACE_PRESERVING_MODULES
172                .iter()
173                .map(|module| (*module).to_string())
174                .collect(),
175        }
176    }
177}
178
179pub fn parse_check_mode(raw: &str) -> Result<CheckMode, String> {
180    CheckMode::parse(raw)
181}
182
183pub fn run_check(root: &Path, include_globs: &[String], mode: CheckMode) -> CheckOutcome {
184    if mode == CheckMode::Off {
185        return CheckOutcome {
186            report: WorkspaceReport {
187                scanned_files: 0,
188                files_with_violations: 0,
189                diagnostics: Vec::new(),
190            },
191            exit_code: 0,
192        };
193    }
194
195    let report = analyze_workspace(root, include_globs);
196    let exit_code = check_exit_code(&report, mode);
197    CheckOutcome { report, exit_code }
198}
199
200fn check_exit_code(report: &WorkspaceReport, mode: CheckMode) -> u8 {
201    if report.error_count() > 0 {
202        return 1;
203    }
204
205    if report.policy_violation_count() == 0 || mode == CheckMode::Warn {
206        0
207    } else {
208        2
209    }
210}
211
212pub fn analyze_file(path: &Path, src: &str) -> AnalysisResult {
213    analyze_file_with_settings(path, src, &NamespaceSettings::default())
214}
215
216fn analyze_file_with_settings(
217    path: &Path,
218    src: &str,
219    settings: &NamespaceSettings,
220) -> AnalysisResult {
221    let parsed = match syn::parse_file(src) {
222        Ok(file) => file,
223        Err(err) => {
224            return AnalysisResult {
225                diagnostics: vec![Diagnostic {
226                    level: DiagnosticLevel::Error,
227                    file: Some(path.to_path_buf()),
228                    line: None,
229                    code: None,
230                    policy: false,
231                    message: format!("failed to parse rust file: {err}"),
232                }],
233            };
234        }
235    };
236
237    let mut result = AnalysisResult::empty();
238    result
239        .diagnostics
240        .extend(namespace::analyze_namespace_rules(path, &parsed, settings).diagnostics);
241    result
242        .diagnostics
243        .extend(api_shape::analyze_api_shape_rules(path, &parsed, settings).diagnostics);
244    result.diagnostics.sort();
245    result
246}
247
248pub fn analyze_workspace(root: &Path, include_globs: &[String]) -> WorkspaceReport {
249    let mut diagnostics = Vec::new();
250    let workspace_defaults = load_workspace_settings(root, &mut diagnostics);
251    let rust_files = match collect_rust_files(root, include_globs) {
252        Ok(files) => files,
253        Err(err) => {
254            diagnostics.push(Diagnostic {
255                level: DiagnosticLevel::Error,
256                file: None,
257                line: None,
258                code: None,
259                policy: false,
260                message: format!("failed to discover rust files: {err}"),
261            });
262            return WorkspaceReport {
263                scanned_files: 0,
264                files_with_violations: 0,
265                diagnostics,
266            };
267        }
268    };
269
270    if rust_files.is_empty() {
271        diagnostics.push(Diagnostic {
272            level: DiagnosticLevel::Warning,
273            file: None,
274            line: None,
275            code: None,
276            policy: false,
277            message:
278                "no Rust files were discovered; pass --include <path>... or run from a crate/workspace root"
279                    .to_string(),
280        });
281    }
282
283    let mut files_with_violations = BTreeSet::new();
284    let mut package_cache = BTreeMap::new();
285
286    for file in &rust_files {
287        let src = match fs::read_to_string(file) {
288            Ok(src) => src,
289            Err(err) => {
290                diagnostics.push(Diagnostic {
291                    level: DiagnosticLevel::Error,
292                    file: Some(file.clone()),
293                    line: None,
294                    code: None,
295                    policy: false,
296                    message: format!("failed to read file: {err}"),
297                });
298                continue;
299            }
300        };
301
302        let settings = settings_for_file(root, file, &workspace_defaults, &mut package_cache);
303        let analysis = analyze_file_with_settings(file, &src, &settings);
304        if !analysis.diagnostics.is_empty() {
305            files_with_violations.insert(file.clone());
306        }
307        diagnostics.extend(analysis.diagnostics);
308    }
309
310    diagnostics.sort();
311
312    WorkspaceReport {
313        scanned_files: rust_files.len(),
314        files_with_violations: files_with_violations.len(),
315        diagnostics,
316    }
317}
318
319fn load_workspace_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> NamespaceSettings {
320    let manifest_path = root.join("Cargo.toml");
321    let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
322        return NamespaceSettings::default();
323    };
324
325    let manifest: toml::Value = match toml::from_str(&manifest_src) {
326        Ok(manifest) => manifest,
327        Err(err) => {
328            diagnostics.push(Diagnostic {
329                level: DiagnosticLevel::Error,
330                file: Some(manifest_path),
331                line: None,
332                code: None,
333                policy: false,
334                message: format!("failed to parse Cargo.toml for modum settings: {err}"),
335            });
336            return NamespaceSettings::default();
337        }
338    };
339
340    parse_settings_from_manifest(
341        manifest
342            .get("workspace")
343            .and_then(toml::Value::as_table)
344            .and_then(|workspace| workspace.get("metadata"))
345            .and_then(toml::Value::as_table)
346            .and_then(|metadata| metadata.get("modum")),
347        &manifest_path,
348        diagnostics,
349    )
350    .unwrap_or_default()
351}
352
353fn settings_for_file(
354    root: &Path,
355    file: &Path,
356    workspace_defaults: &NamespaceSettings,
357    cache: &mut BTreeMap<PathBuf, NamespaceSettings>,
358) -> NamespaceSettings {
359    let Some(package_root) = find_package_root(root, file) else {
360        return workspace_defaults.clone();
361    };
362
363    cache
364        .entry(package_root.clone())
365        .or_insert_with(|| load_package_settings(&package_root, workspace_defaults))
366        .clone()
367}
368
369fn load_package_settings(root: &Path, workspace_defaults: &NamespaceSettings) -> NamespaceSettings {
370    let manifest_path = root.join("Cargo.toml");
371    let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
372        return workspace_defaults.clone();
373    };
374    let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src) else {
375        return workspace_defaults.clone();
376    };
377
378    parse_settings_from_manifest(
379        manifest
380            .get("package")
381            .and_then(toml::Value::as_table)
382            .and_then(|package| package.get("metadata"))
383            .and_then(toml::Value::as_table)
384            .and_then(|metadata| metadata.get("modum")),
385        &manifest_path,
386        &mut Vec::new(),
387    )
388    .unwrap_or_else(|| workspace_defaults.clone())
389}
390
391fn parse_settings_from_manifest(
392    value: Option<&toml::Value>,
393    manifest_path: &Path,
394    diagnostics: &mut Vec<Diagnostic>,
395) -> Option<NamespaceSettings> {
396    let table = value?.as_table()?;
397    let mut settings = NamespaceSettings::default();
398
399    if let Some(values) = parse_string_set_field(table, "generic_nouns", manifest_path, diagnostics)
400    {
401        settings.generic_nouns = values;
402    }
403    if let Some(values) = parse_string_set_field(table, "weak_modules", manifest_path, diagnostics)
404    {
405        settings.weak_modules = values;
406    }
407    if let Some(values) =
408        parse_string_set_field(table, "catch_all_modules", manifest_path, diagnostics)
409    {
410        settings.catch_all_modules = values;
411    }
412    if let Some(values) =
413        parse_string_set_field(table, "organizational_modules", manifest_path, diagnostics)
414    {
415        settings.organizational_modules = values;
416    }
417    if let Some(values) = parse_string_set_field(
418        table,
419        "namespace_preserving_modules",
420        manifest_path,
421        diagnostics,
422    ) {
423        settings.namespace_preserving_modules = values;
424    }
425
426    Some(settings)
427}
428
429fn parse_string_set_field(
430    table: &toml::value::Table,
431    key: &str,
432    manifest_path: &Path,
433    diagnostics: &mut Vec<Diagnostic>,
434) -> Option<BTreeSet<String>> {
435    let value = table.get(key)?;
436    let Some(array) = value.as_array() else {
437        diagnostics.push(Diagnostic {
438            level: DiagnosticLevel::Error,
439            file: Some(manifest_path.to_path_buf()),
440            line: None,
441            code: None,
442            policy: false,
443            message: format!("`metadata.modum.{key}` must be an array of strings"),
444        });
445        return None;
446    };
447
448    Some(
449        array
450            .iter()
451            .filter_map(toml::Value::as_str)
452            .map(|value| value.to_string())
453            .collect(),
454    )
455}
456
457fn find_package_root(root: &Path, file: &Path) -> Option<PathBuf> {
458    for ancestor in file.ancestors().skip(1) {
459        let manifest_path = ancestor.join("Cargo.toml");
460        if manifest_path.is_file()
461            && let Ok(manifest_src) = fs::read_to_string(&manifest_path)
462            && let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src)
463            && manifest.get("package").is_some_and(toml::Value::is_table)
464        {
465            return Some(ancestor.to_path_buf());
466        }
467        if ancestor == root {
468            break;
469        }
470    }
471    None
472}
473
474pub fn render_pretty_report(report: &WorkspaceReport) -> String {
475    let mut out = String::new();
476
477    let _ = writeln!(&mut out, "modum lint report");
478    let _ = writeln!(&mut out, "files scanned: {}", report.scanned_files);
479    let _ = writeln!(
480        &mut out,
481        "files with violations: {}",
482        report.files_with_violations
483    );
484    let _ = writeln!(
485        &mut out,
486        "diagnostics: {} error(s), {} warning(s)",
487        report.error_count(),
488        report.warning_count()
489    );
490    if report.policy_violation_count() > 0 {
491        let _ = writeln!(
492            &mut out,
493            "policy violations: {}",
494            report.policy_violation_count()
495        );
496    }
497
498    if !report.diagnostics.is_empty() {
499        let _ = writeln!(&mut out);
500        let _ = writeln!(&mut out, "Diagnostics:");
501        for diag in &report.diagnostics {
502            let level = match diag.level {
503                DiagnosticLevel::Warning => "warning",
504                DiagnosticLevel::Error => "error",
505            };
506            let code = diag
507                .code
508                .as_deref()
509                .map(|code| format!(" ({code})"))
510                .unwrap_or_default();
511            match (&diag.file, diag.line) {
512                (Some(file), Some(line)) => {
513                    let _ = writeln!(
514                        &mut out,
515                        "- [{level}{code}] {}:{line}: {}",
516                        file.display(),
517                        diag.message
518                    );
519                }
520                (Some(file), None) => {
521                    let _ = writeln!(
522                        &mut out,
523                        "- [{level}{code}] {}: {}",
524                        file.display(),
525                        diag.message
526                    );
527                }
528                (None, _) => {
529                    let _ = writeln!(&mut out, "- [{level}{code}] {}", diag.message);
530                }
531            }
532        }
533    }
534
535    out
536}
537
538fn collect_rust_files(root: &Path, include_globs: &[String]) -> io::Result<Vec<PathBuf>> {
539    let mut files = BTreeSet::new();
540    if include_globs.is_empty() {
541        for scan_root in collect_default_scan_roots(root)? {
542            collect_rust_files_in_dir(&scan_root, &mut files);
543        }
544    } else {
545        for entry in include_globs {
546            let candidate = root.join(entry);
547            if candidate.is_file() && is_rust_file(&candidate) {
548                files.insert(candidate);
549            } else if candidate.is_dir() {
550                collect_rust_files_in_dir(&candidate, &mut files);
551            }
552        }
553    }
554    Ok(files.into_iter().collect())
555}
556
557fn collect_default_scan_roots(root: &Path) -> io::Result<Vec<PathBuf>> {
558    let mut scan_roots = BTreeSet::new();
559    let manifest_path = root.join("Cargo.toml");
560
561    if !manifest_path.is_file() {
562        add_src_root(root, &mut scan_roots);
563        return Ok(scan_roots.into_iter().collect());
564    }
565
566    let manifest_src = fs::read_to_string(&manifest_path)?;
567    let manifest: toml::Value = toml::from_str(&manifest_src).map_err(|err| {
568        io::Error::new(
569            io::ErrorKind::InvalidData,
570            format!("failed to parse {}: {err}", manifest_path.display()),
571        )
572    })?;
573
574    let root_is_package = manifest.get("package").is_some_and(toml::Value::is_table);
575    if root_is_package {
576        add_src_root(root, &mut scan_roots);
577    }
578
579    if let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) {
580        let excluded = parse_workspace_patterns(workspace.get("exclude"));
581        for member_pattern in parse_workspace_patterns(workspace.get("members")) {
582            for member_root in resolve_workspace_member_pattern(root, &member_pattern)? {
583                if is_excluded_member(root, &member_root, &excluded)? {
584                    continue;
585                }
586                add_src_root(&member_root, &mut scan_roots);
587            }
588        }
589    } else if !root_is_package {
590        add_src_root(root, &mut scan_roots);
591    }
592
593    Ok(scan_roots.into_iter().collect())
594}
595
596fn parse_workspace_patterns(value: Option<&toml::Value>) -> Vec<String> {
597    value
598        .and_then(toml::Value::as_array)
599        .into_iter()
600        .flatten()
601        .filter_map(toml::Value::as_str)
602        .map(std::string::ToString::to_string)
603        .collect()
604}
605
606fn resolve_workspace_member_pattern(root: &Path, pattern: &str) -> io::Result<Vec<PathBuf>> {
607    let candidate = root.join(pattern);
608    if !contains_glob_meta(pattern) {
609        if candidate.is_dir() {
610            return Ok(vec![candidate]);
611        }
612        if candidate
613            .file_name()
614            .is_some_and(|name| name == "Cargo.toml")
615            && let Some(parent) = candidate.parent()
616        {
617            return Ok(vec![parent.to_path_buf()]);
618        }
619        return Ok(Vec::new());
620    }
621
622    let escaped_root = Pattern::escape(&root.to_string_lossy());
623    let normalized_pattern = pattern.replace('\\', "/");
624    let full_pattern = format!("{escaped_root}/{normalized_pattern}");
625    let mut paths = Vec::new();
626    let matches = glob(&full_pattern).map_err(|err| {
627        io::Error::new(
628            io::ErrorKind::InvalidInput,
629            format!("invalid workspace member pattern `{pattern}`: {err}"),
630        )
631    })?;
632
633    for entry in matches {
634        let path = entry
635            .map_err(|err| io::Error::other(format!("failed to expand `{pattern}`: {err}")))?;
636        if path.is_dir() {
637            paths.push(path);
638            continue;
639        }
640        if path.file_name().is_some_and(|name| name == "Cargo.toml")
641            && let Some(parent) = path.parent()
642        {
643            paths.push(parent.to_path_buf());
644        }
645    }
646
647    Ok(paths)
648}
649
650fn contains_glob_meta(pattern: &str) -> bool {
651    pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
652}
653
654fn is_excluded_member(root: &Path, member_root: &Path, excluded: &[String]) -> io::Result<bool> {
655    let relative = member_root
656        .strip_prefix(root)
657        .unwrap_or(member_root)
658        .to_string_lossy()
659        .replace('\\', "/");
660    for pattern in excluded {
661        let matcher = Pattern::new(pattern).map_err(|err| {
662            io::Error::new(
663                io::ErrorKind::InvalidInput,
664                format!("invalid workspace exclude pattern `{pattern}`: {err}"),
665            )
666        })?;
667        if matcher.matches(&relative) {
668            return Ok(true);
669        }
670    }
671    Ok(false)
672}
673
674fn add_src_root(root: &Path, scan_roots: &mut BTreeSet<PathBuf>) {
675    let src = root.join("src");
676    if src.is_dir() {
677        scan_roots.insert(src);
678    }
679}
680
681fn collect_rust_files_in_dir(dir: &Path, files: &mut BTreeSet<PathBuf>) {
682    for entry in WalkDir::new(dir)
683        .into_iter()
684        .filter_map(Result::ok)
685        .filter(|entry| entry.file_type().is_file())
686    {
687        let path = entry.path();
688        if is_rust_file(path) {
689            files.insert(path.to_path_buf());
690        }
691    }
692}
693
694fn is_rust_file(path: &Path) -> bool {
695    path.extension().is_some_and(|ext| ext == "rs")
696}
697
698pub(crate) fn is_public(vis: &syn::Visibility) -> bool {
699    !matches!(vis, syn::Visibility::Inherited)
700}
701
702pub(crate) fn unraw_ident(ident: &syn::Ident) -> String {
703    let text = ident.to_string();
704    text.strip_prefix("r#").unwrap_or(&text).to_string()
705}
706
707pub(crate) fn split_segments(name: &str) -> Vec<String> {
708    if name.contains('_') {
709        return name
710            .split('_')
711            .filter(|segment| !segment.is_empty())
712            .map(std::string::ToString::to_string)
713            .collect();
714    }
715
716    let chars: Vec<(usize, char)> = name.char_indices().collect();
717    if chars.is_empty() {
718        return Vec::new();
719    }
720
721    let mut starts = vec![0usize];
722
723    for i in 1..chars.len() {
724        let prev = chars[i - 1].1;
725        let curr = chars[i].1;
726        let next = chars.get(i + 1).map(|(_, c)| *c);
727
728        let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
729        let acronym_to_word = prev.is_ascii_uppercase()
730            && curr.is_ascii_uppercase()
731            && next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
732
733        if lower_to_upper || acronym_to_word {
734            starts.push(chars[i].0);
735        }
736    }
737
738    let mut out = Vec::with_capacity(starts.len());
739    for (idx, start) in starts.iter().enumerate() {
740        let end = if let Some(next) = starts.get(idx + 1) {
741            *next
742        } else {
743            name.len()
744        };
745        let seg = &name[*start..end];
746        if !seg.is_empty() {
747            out.push(seg.to_string());
748        }
749    }
750
751    out
752}
753
754pub(crate) fn normalize_segment(segment: &str) -> String {
755    segment.to_ascii_lowercase()
756}
757
758#[cfg(test)]
759mod tests {
760    use super::{
761        CheckMode, Diagnostic, DiagnosticLevel, NamespaceSettings, WorkspaceReport,
762        check_exit_code, parse_check_mode, split_segments,
763    };
764
765    #[test]
766    fn splits_pascal_camel_snake_and_acronyms() {
767        assert_eq!(split_segments("WhatEver"), vec!["What", "Ever"]);
768        assert_eq!(split_segments("whatEver"), vec!["what", "Ever"]);
769        assert_eq!(split_segments("what_ever"), vec!["what", "ever"]);
770        assert_eq!(split_segments("HTTPServer"), vec!["HTTP", "Server"]);
771    }
772
773    #[test]
774    fn parses_check_modes() {
775        assert_eq!(parse_check_mode("off"), Ok(CheckMode::Off));
776        assert_eq!(parse_check_mode("warn"), Ok(CheckMode::Warn));
777        assert_eq!(parse_check_mode("deny"), Ok(CheckMode::Deny));
778    }
779
780    #[test]
781    fn rejects_invalid_check_mode() {
782        let err = parse_check_mode("strict").unwrap_err();
783        assert!(err.contains("expected off|warn|deny"));
784    }
785
786    #[test]
787    fn check_exit_code_follows_warn_and_deny_semantics() {
788        let clean = WorkspaceReport {
789            scanned_files: 1,
790            files_with_violations: 0,
791            diagnostics: Vec::new(),
792        };
793        assert_eq!(check_exit_code(&clean, CheckMode::Warn), 0);
794        assert_eq!(check_exit_code(&clean, CheckMode::Deny), 0);
795
796        let with_policy = WorkspaceReport {
797            scanned_files: 1,
798            files_with_violations: 1,
799            diagnostics: vec![Diagnostic {
800                level: DiagnosticLevel::Warning,
801                file: None,
802                line: None,
803                code: Some("lint".to_string()),
804                policy: true,
805                message: "warning".to_string(),
806            }],
807        };
808        assert_eq!(check_exit_code(&with_policy, CheckMode::Warn), 0);
809        assert_eq!(check_exit_code(&with_policy, CheckMode::Deny), 2);
810
811        let with_error = WorkspaceReport {
812            scanned_files: 1,
813            files_with_violations: 1,
814            diagnostics: vec![Diagnostic {
815                level: DiagnosticLevel::Error,
816                file: None,
817                line: None,
818                code: None,
819                policy: false,
820                message: "error".to_string(),
821            }],
822        };
823        assert_eq!(check_exit_code(&with_error, CheckMode::Warn), 1);
824        assert_eq!(check_exit_code(&with_error, CheckMode::Deny), 1);
825    }
826
827    #[test]
828    fn namespace_settings_defaults_cover_generic_nouns_and_weak_modules() {
829        let settings = NamespaceSettings::default();
830        assert!(settings.generic_nouns.contains("Repository"));
831        assert!(settings.generic_nouns.contains("Id"));
832        assert!(settings.generic_nouns.contains("Outcome"));
833        assert!(settings.weak_modules.contains("storage"));
834        assert!(settings.catch_all_modules.contains("helpers"));
835        assert!(settings.organizational_modules.contains("error"));
836        assert!(settings.organizational_modules.contains("request"));
837        assert!(settings.organizational_modules.contains("response"));
838        assert!(settings.namespace_preserving_modules.contains("email"));
839        assert!(settings.namespace_preserving_modules.contains("components"));
840        assert!(settings.namespace_preserving_modules.contains("partials"));
841        assert!(!settings.namespace_preserving_modules.contains("views"));
842        assert!(!settings.namespace_preserving_modules.contains("handlers"));
843    }
844}