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