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}