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