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}