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