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