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}