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 diagnostic;
14mod namespace;
15
16pub use diagnostic::{
17 Diagnostic, DiagnosticClass, DiagnosticCodeInfo, DiagnosticFix, DiagnosticFixKind,
18 DiagnosticLevel, DiagnosticSelection, LintProfile, diagnostic_code_info,
19};
20
21const DEFAULT_GENERIC_NOUNS: &[&str] = &[
22 "Id",
23 "Repository",
24 "Service",
25 "Error",
26 "Command",
27 "Request",
28 "Response",
29 "Outcome",
30];
31
32const DEFAULT_WEAK_MODULES: &[&str] = &[
33 "storage",
34 "transport",
35 "infra",
36 "common",
37 "misc",
38 "helpers",
39 "helper",
40 "types",
41 "util",
42 "utils",
43];
44
45const DEFAULT_CATCH_ALL_MODULES: &[&str] = &[
46 "common", "misc", "helpers", "helper", "types", "util", "utils",
47];
48
49const DEFAULT_ORGANIZATIONAL_MODULES: &[&str] = &["error", "errors", "request", "response"];
50
51const DEFAULT_NAMESPACE_PRESERVING_MODULES: &[&str] = &[
52 "auth",
53 "command",
54 "components",
55 "email",
56 "error",
57 "http",
58 "page",
59 "partials",
60 "policy",
61 "query",
62 "repo",
63 "store",
64 "trace",
65 "storage",
66 "transport",
67 "infra",
68 "write_back",
69];
70
71const DEFAULT_SEMANTIC_STRING_SCALARS: &[&str] =
72 &["email", "url", "uri", "path", "locale", "currency", "ip"];
73
74const DEFAULT_SEMANTIC_NUMERIC_SCALARS: &[&str] =
75 &["duration", "timeout", "ttl", "timestamp", "port"];
76
77const DEFAULT_KEY_VALUE_BAG_NAMES: &[&str] = &[
78 "metadata",
79 "attribute",
80 "attributes",
81 "header",
82 "headers",
83 "param",
84 "params",
85 "tag",
86 "tags",
87];
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct AnalysisResult {
91 pub diagnostics: Vec<Diagnostic>,
92}
93
94impl AnalysisResult {
95 fn empty() -> Self {
96 Self {
97 diagnostics: Vec::new(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct WorkspaceReport {
104 pub scanned_files: usize,
105 pub files_with_violations: usize,
106 pub diagnostics: Vec<Diagnostic>,
107}
108
109impl WorkspaceReport {
110 pub fn error_count(&self) -> usize {
111 self.diagnostics
112 .iter()
113 .filter(|diag| diag.is_error())
114 .count()
115 }
116
117 pub fn warning_count(&self) -> usize {
118 self.diagnostics
119 .iter()
120 .filter(|diag| !diag.is_error())
121 .count()
122 }
123
124 pub fn policy_warning_count(&self) -> usize {
125 self.diagnostics
126 .iter()
127 .filter(|diag| diag.is_policy_warning())
128 .count()
129 }
130
131 pub fn advisory_warning_count(&self) -> usize {
132 self.diagnostics
133 .iter()
134 .filter(|diag| diag.is_advisory_warning())
135 .count()
136 }
137
138 pub fn policy_violation_count(&self) -> usize {
139 self.diagnostics
140 .iter()
141 .filter(|diag| diag.is_policy_violation())
142 .count()
143 }
144
145 pub fn filtered(&self, selection: DiagnosticSelection) -> Self {
146 let diagnostics = self
147 .diagnostics
148 .iter()
149 .filter(|diag| selection.includes(diag))
150 .cloned()
151 .collect::<Vec<_>>();
152 let files_with_violations = diagnostics
153 .iter()
154 .filter_map(|diag| diag.file.as_ref())
155 .collect::<BTreeSet<_>>()
156 .len();
157
158 Self {
159 scanned_files: self.scanned_files,
160 files_with_violations,
161 diagnostics,
162 }
163 }
164
165 pub fn filtered_by_profile(&self, profile: LintProfile) -> Self {
166 let diagnostics = self
167 .diagnostics
168 .iter()
169 .filter(|diag| diag.included_in_profile(profile))
170 .cloned()
171 .collect::<Vec<_>>();
172 let files_with_violations = diagnostics
173 .iter()
174 .filter_map(|diag| diag.file.as_ref())
175 .collect::<BTreeSet<_>>()
176 .len();
177
178 Self {
179 scanned_files: self.scanned_files,
180 files_with_violations,
181 diagnostics,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum CheckMode {
188 Off,
189 Warn,
190 Deny,
191}
192
193impl CheckMode {
194 pub fn parse(raw: &str) -> Result<Self, String> {
195 raw.parse()
196 }
197}
198
199impl std::str::FromStr for CheckMode {
200 type Err = String;
201
202 fn from_str(raw: &str) -> Result<Self, Self::Err> {
203 match raw {
204 "off" => Ok(Self::Off),
205 "warn" => Ok(Self::Warn),
206 "deny" => Ok(Self::Deny),
207 _ => Err(format!("invalid mode `{raw}`; expected off|warn|deny")),
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213pub struct CheckOutcome {
214 pub report: WorkspaceReport,
215 pub exit_code: u8,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Default)]
219pub struct ScanSettings {
220 pub include: Vec<String>,
221 pub exclude: Vec<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Default)]
225pub struct AnalysisSettings {
226 pub scan: ScanSettings,
227 pub profile: Option<LintProfile>,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231struct NamespaceSettings {
232 generic_nouns: BTreeSet<String>,
233 weak_modules: BTreeSet<String>,
234 catch_all_modules: BTreeSet<String>,
235 organizational_modules: BTreeSet<String>,
236 namespace_preserving_modules: BTreeSet<String>,
237 semantic_string_scalars: BTreeSet<String>,
238 semantic_numeric_scalars: BTreeSet<String>,
239 key_value_bag_names: BTreeSet<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243struct PackageSettings {
244 namespace: NamespaceSettings,
245 profile: Option<LintProfile>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249struct FileSettings {
250 namespace: NamespaceSettings,
251 profile: LintProfile,
252}
253
254impl Default for NamespaceSettings {
255 fn default() -> Self {
256 Self {
257 generic_nouns: DEFAULT_GENERIC_NOUNS
258 .iter()
259 .map(|noun| (*noun).to_string())
260 .collect(),
261 weak_modules: DEFAULT_WEAK_MODULES
262 .iter()
263 .map(|module| (*module).to_string())
264 .collect(),
265 catch_all_modules: DEFAULT_CATCH_ALL_MODULES
266 .iter()
267 .map(|module| (*module).to_string())
268 .collect(),
269 organizational_modules: DEFAULT_ORGANIZATIONAL_MODULES
270 .iter()
271 .map(|module| (*module).to_string())
272 .collect(),
273 namespace_preserving_modules: DEFAULT_NAMESPACE_PRESERVING_MODULES
274 .iter()
275 .map(|module| (*module).to_string())
276 .collect(),
277 semantic_string_scalars: DEFAULT_SEMANTIC_STRING_SCALARS
278 .iter()
279 .map(|name| (*name).to_string())
280 .collect(),
281 semantic_numeric_scalars: DEFAULT_SEMANTIC_NUMERIC_SCALARS
282 .iter()
283 .map(|name| (*name).to_string())
284 .collect(),
285 key_value_bag_names: DEFAULT_KEY_VALUE_BAG_NAMES
286 .iter()
287 .map(|name| (*name).to_string())
288 .collect(),
289 }
290 }
291}
292
293pub fn parse_check_mode(raw: &str) -> Result<CheckMode, String> {
294 CheckMode::parse(raw)
295}
296
297pub fn parse_lint_profile(raw: &str) -> Result<LintProfile, String> {
298 raw.parse()
299}
300
301pub fn render_diagnostic_explanation(code: &str) -> Option<String> {
302 let info = diagnostic_code_info(code)?;
303 let mut rendered = format!(
304 "{code}\nprofile: {}\nsummary: {}",
305 info.profile.as_str(),
306 info.summary,
307 );
308
309 if let Some(details) = diagnostic_explanation_details(code) {
310 for detail in details {
311 let _ = write!(rendered, "\n{detail}");
312 }
313 }
314
315 Some(rendered)
316}
317
318pub fn run_check(root: &Path, include_globs: &[String], mode: CheckMode) -> CheckOutcome {
319 run_check_with_scan_settings(
320 root,
321 &ScanSettings {
322 include: include_globs.to_vec(),
323 exclude: Vec::new(),
324 },
325 mode,
326 )
327}
328
329pub fn run_check_with_scan_settings(
330 root: &Path,
331 scan_settings: &ScanSettings,
332 mode: CheckMode,
333) -> CheckOutcome {
334 run_check_with_settings(
335 root,
336 &AnalysisSettings {
337 scan: scan_settings.clone(),
338 profile: None,
339 },
340 mode,
341 )
342}
343
344pub fn run_check_with_settings(
345 root: &Path,
346 settings: &AnalysisSettings,
347 mode: CheckMode,
348) -> CheckOutcome {
349 if mode == CheckMode::Off {
350 return CheckOutcome {
351 report: WorkspaceReport {
352 scanned_files: 0,
353 files_with_violations: 0,
354 diagnostics: Vec::new(),
355 },
356 exit_code: 0,
357 };
358 }
359
360 let report = analyze_workspace_with_settings(root, settings);
361 let exit_code = check_exit_code(&report, mode);
362 CheckOutcome { report, exit_code }
363}
364
365fn check_exit_code(report: &WorkspaceReport, mode: CheckMode) -> u8 {
366 if report.error_count() > 0 {
367 return 1;
368 }
369
370 if report.policy_violation_count() == 0 || mode == CheckMode::Warn {
371 0
372 } else {
373 2
374 }
375}
376
377pub fn analyze_file(path: &Path, src: &str) -> AnalysisResult {
378 analyze_file_with_settings(path, src, &NamespaceSettings::default())
379}
380
381fn analyze_file_with_settings(
382 path: &Path,
383 src: &str,
384 settings: &NamespaceSettings,
385) -> AnalysisResult {
386 let parsed = match syn::parse_file(src) {
387 Ok(file) => file,
388 Err(err) => {
389 return AnalysisResult {
390 diagnostics: vec![Diagnostic::error(
391 Some(path.to_path_buf()),
392 None,
393 format!("failed to parse rust file: {err}"),
394 )],
395 };
396 }
397 };
398
399 let mut result = AnalysisResult::empty();
400 result
401 .diagnostics
402 .extend(namespace::analyze_namespace_rules(path, &parsed, settings).diagnostics);
403 result
404 .diagnostics
405 .extend(api_shape::analyze_api_shape_rules(path, &parsed, settings).diagnostics);
406 result.diagnostics.sort();
407 result
408}
409
410pub fn analyze_workspace(root: &Path, include_globs: &[String]) -> WorkspaceReport {
411 analyze_workspace_with_scan_settings(
412 root,
413 &ScanSettings {
414 include: include_globs.to_vec(),
415 exclude: Vec::new(),
416 },
417 )
418}
419
420pub fn analyze_workspace_with_scan_settings(
421 root: &Path,
422 cli_scan_settings: &ScanSettings,
423) -> WorkspaceReport {
424 analyze_workspace_with_settings(
425 root,
426 &AnalysisSettings {
427 scan: cli_scan_settings.clone(),
428 profile: None,
429 },
430 )
431}
432
433pub fn analyze_workspace_with_settings(
434 root: &Path,
435 cli_settings: &AnalysisSettings,
436) -> WorkspaceReport {
437 let mut diagnostics = Vec::new();
438 let workspace_defaults = load_workspace_settings(root, &mut diagnostics);
439 let repo_profile = load_repo_profile(root, &mut diagnostics);
440 let repo_scan_settings = load_repo_scan_settings(root, &mut diagnostics);
441 let effective_scan_settings = effective_scan_settings(&repo_scan_settings, &cli_settings.scan);
442 let rust_files = match collect_rust_files(
443 root,
444 &effective_scan_settings.include,
445 &effective_scan_settings.exclude,
446 ) {
447 Ok(files) => files,
448 Err(err) => {
449 diagnostics.push(Diagnostic::error(
450 None,
451 None,
452 format!("failed to discover rust files: {err}"),
453 ));
454 return WorkspaceReport {
455 scanned_files: 0,
456 files_with_violations: 0,
457 diagnostics,
458 };
459 }
460 };
461
462 if rust_files.is_empty() {
463 diagnostics.push(Diagnostic::warning(
464 None,
465 None,
466 "no Rust files were discovered; pass --include <path>... or run from a crate/workspace root",
467 ));
468 }
469
470 let mut files_with_violations = BTreeSet::new();
471 let mut package_cache = BTreeMap::new();
472
473 for file in &rust_files {
474 let src = match fs::read_to_string(file) {
475 Ok(src) => src,
476 Err(err) => {
477 diagnostics.push(Diagnostic::error(
478 Some(file.clone()),
479 None,
480 format!("failed to read file: {err}"),
481 ));
482 continue;
483 }
484 };
485
486 let settings = settings_for_file(
487 root,
488 file,
489 &workspace_defaults,
490 repo_profile,
491 cli_settings.profile,
492 &mut package_cache,
493 &mut diagnostics,
494 );
495 let mut analysis = analyze_file_with_settings(file, &src, &settings.namespace);
496 analysis
497 .diagnostics
498 .retain(|diag| diag.included_in_profile(settings.profile));
499 if !analysis.diagnostics.is_empty() {
500 files_with_violations.insert(file.clone());
501 }
502 diagnostics.extend(analysis.diagnostics);
503 }
504
505 diagnostics.sort();
506
507 WorkspaceReport {
508 scanned_files: rust_files.len(),
509 files_with_violations: files_with_violations.len(),
510 diagnostics,
511 }
512}
513
514fn effective_scan_settings(
515 repo_defaults: &ScanSettings,
516 cli_overrides: &ScanSettings,
517) -> ScanSettings {
518 let include = if cli_overrides.include.is_empty() {
519 repo_defaults.include.clone()
520 } else {
521 cli_overrides.include.clone()
522 };
523 let mut exclude = repo_defaults.exclude.clone();
524 exclude.extend(cli_overrides.exclude.iter().cloned());
525 ScanSettings { include, exclude }
526}
527
528fn load_workspace_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> NamespaceSettings {
529 let manifest_path = root.join("Cargo.toml");
530 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
531 return NamespaceSettings::default();
532 };
533
534 let manifest: toml::Value = match toml::from_str(&manifest_src) {
535 Ok(manifest) => manifest,
536 Err(err) => {
537 diagnostics.push(Diagnostic::error(
538 Some(manifest_path),
539 None,
540 format!("failed to parse Cargo.toml for modum settings: {err}"),
541 ));
542 return NamespaceSettings::default();
543 }
544 };
545
546 parse_settings_from_manifest(
547 manifest
548 .get("workspace")
549 .and_then(toml::Value::as_table)
550 .and_then(|workspace| workspace.get("metadata"))
551 .and_then(toml::Value::as_table)
552 .and_then(|metadata| metadata.get("modum")),
553 &NamespaceSettings::default(),
554 &manifest_path,
555 diagnostics,
556 )
557 .unwrap_or_default()
558}
559
560fn load_repo_scan_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> ScanSettings {
561 let manifest_path = root.join("Cargo.toml");
562 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
563 return ScanSettings::default();
564 };
565
566 let manifest: toml::Value = match toml::from_str(&manifest_src) {
567 Ok(manifest) => manifest,
568 Err(err) => {
569 diagnostics.push(Diagnostic::error(
570 Some(manifest_path),
571 None,
572 format!("failed to parse Cargo.toml for modum settings: {err}"),
573 ));
574 return ScanSettings::default();
575 }
576 };
577
578 parse_scan_settings_from_manifest(
579 manifest
580 .get("workspace")
581 .and_then(toml::Value::as_table)
582 .and_then(|workspace| workspace.get("metadata"))
583 .and_then(toml::Value::as_table)
584 .and_then(|metadata| metadata.get("modum"))
585 .or_else(|| {
586 manifest
587 .get("package")
588 .and_then(toml::Value::as_table)
589 .and_then(|package| package.get("metadata"))
590 .and_then(toml::Value::as_table)
591 .and_then(|metadata| metadata.get("modum"))
592 }),
593 &manifest_path,
594 diagnostics,
595 )
596 .unwrap_or_default()
597}
598
599fn load_repo_profile(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> Option<LintProfile> {
600 let manifest_path = root.join("Cargo.toml");
601 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
602 return None;
603 };
604
605 let manifest: toml::Value = match toml::from_str(&manifest_src) {
606 Ok(manifest) => manifest,
607 Err(err) => {
608 diagnostics.push(Diagnostic::error(
609 Some(manifest_path),
610 None,
611 format!("failed to parse Cargo.toml for modum settings: {err}"),
612 ));
613 return None;
614 }
615 };
616
617 parse_profile_from_manifest(
618 manifest
619 .get("workspace")
620 .and_then(toml::Value::as_table)
621 .and_then(|workspace| workspace.get("metadata"))
622 .and_then(toml::Value::as_table)
623 .and_then(|metadata| metadata.get("modum"))
624 .or_else(|| {
625 manifest
626 .get("package")
627 .and_then(toml::Value::as_table)
628 .and_then(|package| package.get("metadata"))
629 .and_then(toml::Value::as_table)
630 .and_then(|metadata| metadata.get("modum"))
631 }),
632 &manifest_path,
633 diagnostics,
634 )
635}
636
637fn settings_for_file(
638 root: &Path,
639 file: &Path,
640 workspace_defaults: &NamespaceSettings,
641 repo_profile: Option<LintProfile>,
642 cli_profile: Option<LintProfile>,
643 cache: &mut BTreeMap<PathBuf, PackageSettings>,
644 diagnostics: &mut Vec<Diagnostic>,
645) -> FileSettings {
646 let Some(package_root) = find_package_root(root, file) else {
647 return FileSettings {
648 namespace: workspace_defaults.clone(),
649 profile: resolve_profile(cli_profile, None, repo_profile),
650 };
651 };
652
653 if let Some(settings) = cache.get(&package_root) {
654 return FileSettings {
655 namespace: settings.namespace.clone(),
656 profile: resolve_profile(cli_profile, settings.profile, repo_profile),
657 };
658 }
659
660 let settings = load_package_settings(&package_root, workspace_defaults, diagnostics);
661 cache.insert(package_root, settings.clone());
662 FileSettings {
663 namespace: settings.namespace,
664 profile: resolve_profile(cli_profile, settings.profile, repo_profile),
665 }
666}
667
668fn load_package_settings(
669 root: &Path,
670 workspace_defaults: &NamespaceSettings,
671 diagnostics: &mut Vec<Diagnostic>,
672) -> PackageSettings {
673 let manifest_path = root.join("Cargo.toml");
674 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
675 return PackageSettings {
676 namespace: workspace_defaults.clone(),
677 profile: None,
678 };
679 };
680
681 let manifest = match toml::from_str::<toml::Value>(&manifest_src) {
682 Ok(manifest) => manifest,
683 Err(err) => {
684 diagnostics.push(Diagnostic::error(
685 Some(manifest_path),
686 None,
687 format!("failed to parse Cargo.toml for modum settings: {err}"),
688 ));
689 return PackageSettings {
690 namespace: workspace_defaults.clone(),
691 profile: None,
692 };
693 }
694 };
695
696 let metadata = manifest
697 .get("package")
698 .and_then(toml::Value::as_table)
699 .and_then(|package| package.get("metadata"))
700 .and_then(toml::Value::as_table)
701 .and_then(|metadata| metadata.get("modum"));
702
703 let namespace =
704 parse_settings_from_manifest(metadata, workspace_defaults, &manifest_path, diagnostics)
705 .unwrap_or_else(|| workspace_defaults.clone());
706 let profile = parse_profile_from_manifest(metadata, &manifest_path, diagnostics);
707
708 PackageSettings { namespace, profile }
709}
710
711fn resolve_profile(
712 cli_profile: Option<LintProfile>,
713 package_profile: Option<LintProfile>,
714 repo_profile: Option<LintProfile>,
715) -> LintProfile {
716 cli_profile
717 .or(package_profile)
718 .or(repo_profile)
719 .unwrap_or_default()
720}
721
722fn parse_settings_from_manifest(
723 value: Option<&toml::Value>,
724 base: &NamespaceSettings,
725 manifest_path: &Path,
726 diagnostics: &mut Vec<Diagnostic>,
727) -> Option<NamespaceSettings> {
728 let table = value?.as_table()?;
729 let mut settings = base.clone();
730
731 if let Some(values) = parse_string_set_field(table, "generic_nouns", manifest_path, diagnostics)
732 {
733 settings.generic_nouns = values;
734 }
735 if let Some(values) = parse_string_set_field(table, "weak_modules", manifest_path, diagnostics)
736 {
737 settings.weak_modules = values;
738 }
739 if let Some(values) =
740 parse_string_set_field(table, "catch_all_modules", manifest_path, diagnostics)
741 {
742 settings.catch_all_modules = values;
743 }
744 if let Some(values) =
745 parse_string_set_field(table, "organizational_modules", manifest_path, diagnostics)
746 {
747 settings.organizational_modules = values;
748 }
749 if let Some(values) = parse_string_set_field(
750 table,
751 "namespace_preserving_modules",
752 manifest_path,
753 diagnostics,
754 ) {
755 settings.namespace_preserving_modules = values;
756 }
757 apply_token_family_overrides(
758 &mut settings.semantic_string_scalars,
759 table,
760 "extra_semantic_string_scalars",
761 "ignored_semantic_string_scalars",
762 manifest_path,
763 diagnostics,
764 );
765 apply_token_family_overrides(
766 &mut settings.semantic_numeric_scalars,
767 table,
768 "extra_semantic_numeric_scalars",
769 "ignored_semantic_numeric_scalars",
770 manifest_path,
771 diagnostics,
772 );
773 apply_token_family_overrides(
774 &mut settings.key_value_bag_names,
775 table,
776 "extra_key_value_bag_names",
777 "ignored_key_value_bag_names",
778 manifest_path,
779 diagnostics,
780 );
781
782 Some(settings)
783}
784
785fn parse_scan_settings_from_manifest(
786 value: Option<&toml::Value>,
787 manifest_path: &Path,
788 diagnostics: &mut Vec<Diagnostic>,
789) -> Option<ScanSettings> {
790 let table = value?.as_table()?;
791 let mut settings = ScanSettings::default();
792
793 if let Some(values) = parse_string_list_field(table, "include", manifest_path, diagnostics) {
794 settings.include = values;
795 }
796 if let Some(values) = parse_string_list_field(table, "exclude", manifest_path, diagnostics) {
797 settings.exclude = values;
798 }
799
800 Some(settings)
801}
802
803fn parse_profile_from_manifest(
804 value: Option<&toml::Value>,
805 manifest_path: &Path,
806 diagnostics: &mut Vec<Diagnostic>,
807) -> Option<LintProfile> {
808 let table = value?.as_table()?;
809 let raw = parse_string_field(table, "profile", manifest_path, diagnostics)?;
810 match raw.parse() {
811 Ok(profile) => Some(profile),
812 Err(err) => {
813 diagnostics.push(Diagnostic::error(
814 Some(manifest_path.to_path_buf()),
815 None,
816 format!("`metadata.modum.profile` {err}"),
817 ));
818 None
819 }
820 }
821}
822
823fn parse_string_field(
824 table: &toml::value::Table,
825 key: &str,
826 manifest_path: &Path,
827 diagnostics: &mut Vec<Diagnostic>,
828) -> Option<String> {
829 let value = table.get(key)?;
830 let Some(value) = value.as_str() else {
831 diagnostics.push(Diagnostic::error(
832 Some(manifest_path.to_path_buf()),
833 None,
834 format!("`metadata.modum.{key}` must be a string"),
835 ));
836 return None;
837 };
838
839 Some(value.to_string())
840}
841
842fn parse_string_set_field(
843 table: &toml::value::Table,
844 key: &str,
845 manifest_path: &Path,
846 diagnostics: &mut Vec<Diagnostic>,
847) -> Option<BTreeSet<String>> {
848 Some(
849 parse_string_values_field(table, key, manifest_path, diagnostics)?
850 .into_iter()
851 .collect(),
852 )
853}
854
855fn parse_string_list_field(
856 table: &toml::value::Table,
857 key: &str,
858 manifest_path: &Path,
859 diagnostics: &mut Vec<Diagnostic>,
860) -> Option<Vec<String>> {
861 parse_string_values_field(table, key, manifest_path, diagnostics)
862}
863
864fn parse_string_values_field(
865 table: &toml::value::Table,
866 key: &str,
867 manifest_path: &Path,
868 diagnostics: &mut Vec<Diagnostic>,
869) -> Option<Vec<String>> {
870 let value = table.get(key)?;
871 let Some(array) = value.as_array() else {
872 diagnostics.push(Diagnostic::error(
873 Some(manifest_path.to_path_buf()),
874 None,
875 format!("`metadata.modum.{key}` must be an array of strings"),
876 ));
877 return None;
878 };
879
880 let mut values = Vec::with_capacity(array.len());
881 for (index, value) in array.iter().enumerate() {
882 let Some(value) = value.as_str() else {
883 diagnostics.push(Diagnostic::error(
884 Some(manifest_path.to_path_buf()),
885 None,
886 format!("`metadata.modum.{key}[{index}]` must be a string"),
887 ));
888 return None;
889 };
890 values.push(value.to_string());
891 }
892
893 Some(values)
894}
895
896fn parse_normalized_string_set_field(
897 table: &toml::value::Table,
898 key: &str,
899 manifest_path: &Path,
900 diagnostics: &mut Vec<Diagnostic>,
901) -> Option<BTreeSet<String>> {
902 Some(
903 parse_string_values_field(table, key, manifest_path, diagnostics)?
904 .into_iter()
905 .map(|value| normalize_segment(&value))
906 .collect(),
907 )
908}
909
910fn apply_token_family_overrides(
911 target: &mut BTreeSet<String>,
912 table: &toml::value::Table,
913 extra_key: &str,
914 ignored_key: &str,
915 manifest_path: &Path,
916 diagnostics: &mut Vec<Diagnostic>,
917) {
918 if let Some(values) =
919 parse_normalized_string_set_field(table, extra_key, manifest_path, diagnostics)
920 {
921 target.extend(values);
922 }
923 if let Some(values) =
924 parse_normalized_string_set_field(table, ignored_key, manifest_path, diagnostics)
925 {
926 for value in values {
927 target.remove(&value);
928 }
929 }
930}
931
932fn diagnostic_explanation_details(code: &str) -> Option<&'static [&'static str]> {
933 match code {
934 "namespace_prelude_glob_import" => Some(&[
935 "why: prelude globs make it harder to see which module gives a name its meaning.",
936 "typical fixes: import the specific items you need or keep the preserving module visible at call sites.",
937 ]),
938 "namespace_glob_preserve_module" => Some(&[
939 "why: broad globs from modules like `http`, `error`, or `query` erase context that often helps readers scan call sites.",
940 "typical fixes: import only the concrete items you need or keep the module qualifier in local code.",
941 ]),
942 "api_anyhow_error_surface" => Some(&[
943 "why: `anyhow` works well internally, but caller-facing boundaries usually read better when the crate owns the error type and variants.",
944 "typical fixes: return a crate-owned error enum or newtype and convert internal failures into that boundary type.",
945 ]),
946 "api_string_error_surface" => Some(&[
947 "why: raw string errors lose structure, variant names, and machine-readable context at the boundary.",
948 "typical fixes: model the boundary error as an enum, a focused struct, or another typed error value with named data.",
949 ]),
950 "api_semantic_string_scalar" => Some(&[
951 "why: names like `email`, `url`, `path`, or `locale` usually carry domain rules that a plain `String` cannot express.",
952 "typical fixes: parse at the boundary into a domain newtype or another focused typed value.",
953 "repo tuning: use `metadata.modum.extra_semantic_string_scalars` or `metadata.modum.ignored_semantic_string_scalars` to adjust the token family.",
954 ]),
955 "api_semantic_numeric_scalar" => Some(&[
956 "why: names like `duration`, `timestamp`, or `port` often want units or domain semantics, not a bare integer.",
957 "typical fixes: use a typed duration, timestamp, port, or small domain newtype at the boundary.",
958 "repo tuning: use `metadata.modum.extra_semantic_numeric_scalars` or `metadata.modum.ignored_semantic_numeric_scalars` to adjust the token family.",
959 ]),
960 "api_raw_key_value_bag" => Some(&[
961 "why: bags like `metadata`, `headers`, or `params` often accrete hidden contracts that are easier to understand once they are typed.",
962 "typical fixes: introduce a focused options struct, metadata type, or dedicated collection wrapper.",
963 "repo tuning: use `metadata.modum.extra_key_value_bag_names` or `metadata.modum.ignored_key_value_bag_names` to adjust the token family.",
964 ]),
965 "api_boolean_flag_cluster" => Some(&[
966 "why: several booleans together usually encode modes or policy choices that are easier to name explicitly.",
967 "typical fixes: group the behavior into a typed options struct, an enum, or a smaller decision object.",
968 ]),
969 "api_manual_flag_set" => Some(&[
970 "why: parallel flag constants and repeated raw bitmask checks usually mean the boundary is modeling a flags type by hand.",
971 "typical fixes: introduce a focused typed flags surface or small domain wrapper instead of exposing raw integer masks.",
972 ]),
973 "api_raw_id_surface" => Some(&[
974 "why: ids often carry validation, formatting, or cross-system meaning that is easy to lose when they stay as bare strings or integers.",
975 "typical fixes: introduce a small id newtype and parse or validate at the boundary.",
976 ]),
977 _ => None,
978 }
979}
980
981fn find_package_root(root: &Path, file: &Path) -> Option<PathBuf> {
982 for ancestor in file.ancestors().skip(1) {
983 let manifest_path = ancestor.join("Cargo.toml");
984 if manifest_path.is_file()
985 && let Ok(manifest_src) = fs::read_to_string(&manifest_path)
986 && let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src)
987 && manifest.get("package").is_some_and(toml::Value::is_table)
988 {
989 return Some(ancestor.to_path_buf());
990 }
991 if ancestor == root {
992 break;
993 }
994 }
995 None
996}
997
998pub fn render_pretty_report(report: &WorkspaceReport) -> String {
999 render_pretty_report_with_selection(report, DiagnosticSelection::All)
1000}
1001
1002pub fn render_pretty_report_with_selection(
1003 report: &WorkspaceReport,
1004 selection: DiagnosticSelection,
1005) -> String {
1006 let filtered = report.filtered(selection);
1007 let mut out = String::new();
1008
1009 let _ = writeln!(&mut out, "modum lint report");
1010 let _ = writeln!(&mut out, "files scanned: {}", filtered.scanned_files);
1011 let _ = writeln!(
1012 &mut out,
1013 "files with violations: {}",
1014 filtered.files_with_violations
1015 );
1016 let _ = writeln!(
1017 &mut out,
1018 "diagnostics: {} error(s), {} policy warning(s), {} advisory warning(s)",
1019 filtered.error_count(),
1020 filtered.policy_warning_count(),
1021 filtered.advisory_warning_count()
1022 );
1023 if let Some(selection_label) = selection.report_label() {
1024 let _ = writeln!(
1025 &mut out,
1026 "showing: {selection_label} (exit code still reflects the full report)"
1027 );
1028 }
1029 if filtered.policy_violation_count() > 0 {
1030 let _ = writeln!(
1031 &mut out,
1032 "policy violations: {}",
1033 filtered.policy_violation_count()
1034 );
1035 }
1036 if filtered.advisory_warning_count() > 0 {
1037 let _ = writeln!(
1038 &mut out,
1039 "advisories: {}",
1040 filtered.advisory_warning_count()
1041 );
1042 }
1043
1044 if !filtered.diagnostics.is_empty() {
1045 let _ = writeln!(&mut out);
1046 render_diagnostic_section(
1047 &mut out,
1048 "Errors:",
1049 filtered.diagnostics.iter().filter(|diag| diag.is_error()),
1050 );
1051 render_diagnostic_section(
1052 &mut out,
1053 "Policy Diagnostics:",
1054 filtered
1055 .diagnostics
1056 .iter()
1057 .filter(|diag| diag.is_policy_warning()),
1058 );
1059 render_diagnostic_section(
1060 &mut out,
1061 "Advisory Diagnostics:",
1062 filtered
1063 .diagnostics
1064 .iter()
1065 .filter(|diag| diag.is_advisory_warning()),
1066 );
1067 }
1068
1069 out
1070}
1071
1072fn render_diagnostic_section<'a>(
1073 out: &mut String,
1074 title: &str,
1075 diagnostics: impl Iterator<Item = &'a Diagnostic>,
1076) {
1077 let diagnostics = diagnostics.collect::<Vec<_>>();
1078 if diagnostics.is_empty() {
1079 return;
1080 }
1081
1082 let _ = writeln!(out, "{title}");
1083 for diag in diagnostics {
1084 let level = match diag.level() {
1085 DiagnosticLevel::Warning => "warning",
1086 DiagnosticLevel::Error => "error",
1087 };
1088 let code = match (diag.code(), diag.profile()) {
1089 (Some(code), Some(profile)) => format!(" ({code}, {})", profile.as_str()),
1090 (Some(code), None) => format!(" ({code})"),
1091 (None, _) => String::new(),
1092 };
1093 let fix = diag
1094 .fix
1095 .as_ref()
1096 .map(|fix| format!(" [fix: {}]", fix.replacement))
1097 .unwrap_or_default();
1098 match (&diag.file, diag.line) {
1099 (Some(file), Some(line)) => {
1100 let _ = writeln!(
1101 out,
1102 "- [{level}{code}] {}:{line}: {}{fix}",
1103 file.display(),
1104 diag.message
1105 );
1106 }
1107 (Some(file), None) => {
1108 let _ = writeln!(
1109 out,
1110 "- [{level}{code}] {}: {}{fix}",
1111 file.display(),
1112 diag.message
1113 );
1114 }
1115 (None, _) => {
1116 let _ = writeln!(out, "- [{level}{code}] {}{fix}", diag.message);
1117 }
1118 }
1119 }
1120 let _ = writeln!(out);
1121}
1122
1123fn collect_rust_files(
1124 root: &Path,
1125 include_globs: &[String],
1126 exclude_globs: &[String],
1127) -> io::Result<Vec<PathBuf>> {
1128 let mut files = BTreeSet::new();
1129 if include_globs.is_empty() {
1130 for scan_root in collect_default_scan_roots(root)? {
1131 collect_rust_files_in_dir(&scan_root, &mut files);
1132 }
1133 } else {
1134 for entry in include_globs {
1135 collect_rust_files_for_entry(root, entry, &mut files)?;
1136 }
1137 }
1138
1139 let mut filtered = Vec::with_capacity(files.len());
1140 for path in files {
1141 if !is_excluded_path(root, &path, exclude_globs)? {
1142 filtered.push(path);
1143 }
1144 }
1145
1146 Ok(filtered)
1147}
1148
1149fn collect_rust_files_for_entry(
1150 root: &Path,
1151 entry: &str,
1152 files: &mut BTreeSet<PathBuf>,
1153) -> io::Result<()> {
1154 let candidate = root.join(entry);
1155 if !contains_glob_meta(entry) {
1156 if candidate.is_file() && is_rust_file(&candidate) {
1157 files.insert(candidate);
1158 } else if candidate.is_dir() {
1159 collect_rust_files_in_dir(&candidate, files);
1160 }
1161 return Ok(());
1162 }
1163
1164 let escaped_root = Pattern::escape(&root.to_string_lossy());
1165 let normalized_pattern = entry.replace('\\', "/");
1166 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
1167 let matches = glob(&full_pattern).map_err(|err| {
1168 io::Error::new(
1169 io::ErrorKind::InvalidInput,
1170 format!("invalid include pattern `{entry}`: {err}"),
1171 )
1172 })?;
1173
1174 for matched in matches {
1175 let path = matched
1176 .map_err(|err| io::Error::other(format!("failed to expand `{entry}`: {err}")))?;
1177 if path.is_file() && is_rust_file(&path) {
1178 files.insert(path);
1179 } else if path.is_dir() {
1180 collect_rust_files_in_dir(&path, files);
1181 }
1182 }
1183
1184 Ok(())
1185}
1186
1187fn is_excluded_path(root: &Path, path: &Path, exclude_globs: &[String]) -> io::Result<bool> {
1188 if exclude_globs.is_empty() {
1189 return Ok(false);
1190 }
1191
1192 let relative = path
1193 .strip_prefix(root)
1194 .unwrap_or(path)
1195 .to_string_lossy()
1196 .replace('\\', "/");
1197 for pattern in exclude_globs {
1198 if contains_glob_meta(pattern) {
1199 let matcher = Pattern::new(pattern).map_err(|err| {
1200 io::Error::new(
1201 io::ErrorKind::InvalidInput,
1202 format!("invalid exclude pattern `{pattern}`: {err}"),
1203 )
1204 })?;
1205 if matcher.matches(&relative) {
1206 return Ok(true);
1207 }
1208 continue;
1209 }
1210
1211 let normalized = pattern.trim_end_matches('/').replace('\\', "/");
1212 if relative == normalized || relative.starts_with(&format!("{normalized}/")) {
1213 return Ok(true);
1214 }
1215 }
1216 Ok(false)
1217}
1218
1219fn collect_default_scan_roots(root: &Path) -> io::Result<Vec<PathBuf>> {
1220 let mut scan_roots = BTreeSet::new();
1221 let manifest_path = root.join("Cargo.toml");
1222
1223 if !manifest_path.is_file() {
1224 add_src_root(root, &mut scan_roots);
1225 return Ok(scan_roots.into_iter().collect());
1226 }
1227
1228 let manifest_src = fs::read_to_string(&manifest_path)?;
1229 let manifest: toml::Value = toml::from_str(&manifest_src).map_err(|err| {
1230 io::Error::new(
1231 io::ErrorKind::InvalidData,
1232 format!("failed to parse {}: {err}", manifest_path.display()),
1233 )
1234 })?;
1235
1236 let root_is_package = manifest.get("package").is_some_and(toml::Value::is_table);
1237 if root_is_package {
1238 add_src_root(root, &mut scan_roots);
1239 }
1240
1241 if let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) {
1242 let excluded = parse_workspace_patterns(workspace.get("exclude"));
1243 for member_pattern in parse_workspace_patterns(workspace.get("members")) {
1244 for member_root in resolve_workspace_member_pattern(root, &member_pattern)? {
1245 if is_excluded_member(root, &member_root, &excluded)? {
1246 continue;
1247 }
1248 add_src_root(&member_root, &mut scan_roots);
1249 }
1250 }
1251 } else if !root_is_package {
1252 add_src_root(root, &mut scan_roots);
1253 }
1254
1255 Ok(scan_roots.into_iter().collect())
1256}
1257
1258fn parse_workspace_patterns(value: Option<&toml::Value>) -> Vec<String> {
1259 value
1260 .and_then(toml::Value::as_array)
1261 .into_iter()
1262 .flatten()
1263 .filter_map(toml::Value::as_str)
1264 .map(std::string::ToString::to_string)
1265 .collect()
1266}
1267
1268fn resolve_workspace_member_pattern(root: &Path, pattern: &str) -> io::Result<Vec<PathBuf>> {
1269 let candidate = root.join(pattern);
1270 if !contains_glob_meta(pattern) {
1271 if candidate.is_dir() {
1272 return Ok(vec![candidate]);
1273 }
1274 if candidate
1275 .file_name()
1276 .is_some_and(|name| name == "Cargo.toml")
1277 && let Some(parent) = candidate.parent()
1278 {
1279 return Ok(vec![parent.to_path_buf()]);
1280 }
1281 return Ok(Vec::new());
1282 }
1283
1284 let escaped_root = Pattern::escape(&root.to_string_lossy());
1285 let normalized_pattern = pattern.replace('\\', "/");
1286 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
1287 let mut paths = Vec::new();
1288 let matches = glob(&full_pattern).map_err(|err| {
1289 io::Error::new(
1290 io::ErrorKind::InvalidInput,
1291 format!("invalid workspace member pattern `{pattern}`: {err}"),
1292 )
1293 })?;
1294
1295 for entry in matches {
1296 let path = entry
1297 .map_err(|err| io::Error::other(format!("failed to expand `{pattern}`: {err}")))?;
1298 if path.is_dir() {
1299 paths.push(path);
1300 continue;
1301 }
1302 if path.file_name().is_some_and(|name| name == "Cargo.toml")
1303 && let Some(parent) = path.parent()
1304 {
1305 paths.push(parent.to_path_buf());
1306 }
1307 }
1308
1309 Ok(paths)
1310}
1311
1312fn contains_glob_meta(pattern: &str) -> bool {
1313 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
1314}
1315
1316fn is_excluded_member(root: &Path, member_root: &Path, excluded: &[String]) -> io::Result<bool> {
1317 let relative = member_root
1318 .strip_prefix(root)
1319 .unwrap_or(member_root)
1320 .to_string_lossy()
1321 .replace('\\', "/");
1322 for pattern in excluded {
1323 let matcher = Pattern::new(pattern).map_err(|err| {
1324 io::Error::new(
1325 io::ErrorKind::InvalidInput,
1326 format!("invalid workspace exclude pattern `{pattern}`: {err}"),
1327 )
1328 })?;
1329 if matcher.matches(&relative) {
1330 return Ok(true);
1331 }
1332 }
1333 Ok(false)
1334}
1335
1336fn add_src_root(root: &Path, scan_roots: &mut BTreeSet<PathBuf>) {
1337 let src = root.join("src");
1338 if src.is_dir() {
1339 scan_roots.insert(src);
1340 }
1341}
1342
1343fn collect_rust_files_in_dir(dir: &Path, files: &mut BTreeSet<PathBuf>) {
1344 for entry in WalkDir::new(dir)
1345 .into_iter()
1346 .filter_map(Result::ok)
1347 .filter(|entry| entry.file_type().is_file())
1348 {
1349 let path = entry.path();
1350 if is_rust_file(path) {
1351 files.insert(path.to_path_buf());
1352 }
1353 }
1354}
1355
1356fn is_rust_file(path: &Path) -> bool {
1357 path.extension().is_some_and(|ext| ext == "rs")
1358}
1359
1360pub(crate) fn is_public(vis: &syn::Visibility) -> bool {
1361 !matches!(vis, syn::Visibility::Inherited)
1362}
1363
1364pub(crate) fn unraw_ident(ident: &syn::Ident) -> String {
1365 let text = ident.to_string();
1366 text.strip_prefix("r#").unwrap_or(&text).to_string()
1367}
1368
1369pub(crate) fn split_segments(name: &str) -> Vec<String> {
1370 if name.contains('_') {
1371 return name
1372 .split('_')
1373 .filter(|segment| !segment.is_empty())
1374 .map(std::string::ToString::to_string)
1375 .collect();
1376 }
1377
1378 let chars: Vec<(usize, char)> = name.char_indices().collect();
1379 if chars.is_empty() {
1380 return Vec::new();
1381 }
1382
1383 let mut starts = vec![0usize];
1384
1385 for i in 1..chars.len() {
1386 let prev = chars[i - 1].1;
1387 let curr = chars[i].1;
1388 let next = chars.get(i + 1).map(|(_, c)| *c);
1389
1390 let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
1391 let acronym_to_word = prev.is_ascii_uppercase()
1392 && curr.is_ascii_uppercase()
1393 && next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
1394
1395 if lower_to_upper || acronym_to_word {
1396 starts.push(chars[i].0);
1397 }
1398 }
1399
1400 let mut out = Vec::with_capacity(starts.len());
1401 for (idx, start) in starts.iter().enumerate() {
1402 let end = if let Some(next) = starts.get(idx + 1) {
1403 *next
1404 } else {
1405 name.len()
1406 };
1407 let seg = &name[*start..end];
1408 if !seg.is_empty() {
1409 out.push(seg.to_string());
1410 }
1411 }
1412
1413 out
1414}
1415
1416pub(crate) fn normalize_segment(segment: &str) -> String {
1417 segment.to_ascii_lowercase()
1418}
1419
1420#[derive(Clone, Copy)]
1421pub(crate) enum NameStyle {
1422 Pascal,
1423 Snake,
1424 ScreamingSnake,
1425}
1426
1427pub(crate) fn detect_name_style(name: &str) -> NameStyle {
1428 if name.contains('_') {
1429 if name
1430 .chars()
1431 .filter(|ch| ch.is_ascii_alphabetic())
1432 .all(|ch| ch.is_ascii_uppercase())
1433 {
1434 NameStyle::ScreamingSnake
1435 } else {
1436 NameStyle::Snake
1437 }
1438 } else {
1439 NameStyle::Pascal
1440 }
1441}
1442
1443pub(crate) fn render_segments(segments: &[String], style: NameStyle) -> String {
1444 match style {
1445 NameStyle::Pascal => segments
1446 .iter()
1447 .map(|segment| {
1448 let lower = segment.to_ascii_lowercase();
1449 let mut chars = lower.chars();
1450 let Some(first) = chars.next() else {
1451 return String::new();
1452 };
1453 let mut rendered = String::new();
1454 rendered.push(first.to_ascii_uppercase());
1455 rendered.extend(chars);
1456 rendered
1457 })
1458 .collect::<Vec<_>>()
1459 .join(""),
1460 NameStyle::Snake => segments
1461 .iter()
1462 .map(|segment| segment.to_ascii_lowercase())
1463 .collect::<Vec<_>>()
1464 .join("_"),
1465 NameStyle::ScreamingSnake => segments
1466 .iter()
1467 .map(|segment| segment.to_ascii_uppercase())
1468 .collect::<Vec<_>>()
1469 .join("_"),
1470 }
1471}
1472
1473pub(crate) fn inferred_file_module_path(path: &Path) -> Vec<String> {
1474 let components = path
1475 .iter()
1476 .map(|component| component.to_string_lossy().to_string())
1477 .collect::<Vec<_>>();
1478 let rel = if let Some(src_idx) = components.iter().rposition(|component| component == "src") {
1479 &components[src_idx + 1..]
1480 } else {
1481 &components[..]
1482 };
1483
1484 if rel.is_empty() || rel.first().is_some_and(|component| component == "bin") {
1485 return Vec::new();
1486 }
1487
1488 let mut module_path = Vec::new();
1489 for (idx, component) in rel.iter().enumerate() {
1490 let is_last = idx + 1 == rel.len();
1491 if is_last {
1492 match component.as_str() {
1493 "lib.rs" | "main.rs" | "mod.rs" => {}
1494 other => {
1495 if let Some(stem) = other.strip_suffix(".rs") {
1496 module_path.push(stem.to_string());
1497 }
1498 }
1499 }
1500 continue;
1501 }
1502
1503 module_path.push(component.to_string());
1504 }
1505
1506 module_path
1507}
1508
1509pub(crate) fn source_root(path: &Path) -> Option<PathBuf> {
1510 let mut root = PathBuf::new();
1511 for component in path.components() {
1512 root.push(component.as_os_str());
1513 if component.as_os_str() == "src" {
1514 return Some(root);
1515 }
1516 }
1517 None
1518}
1519
1520pub(crate) fn parent_module_files(src_root: &Path, prefix: &[String]) -> Vec<PathBuf> {
1521 if prefix.is_empty() {
1522 return vec![src_root.join("lib.rs"), src_root.join("main.rs")];
1523 }
1524
1525 let joined = prefix.join("/");
1526 vec![
1527 src_root.join(format!("{joined}.rs")),
1528 src_root.join(joined).join("mod.rs"),
1529 ]
1530}
1531
1532pub(crate) fn replace_path_fix(replacement: impl Into<String>) -> DiagnosticFix {
1533 DiagnosticFix {
1534 kind: DiagnosticFixKind::ReplacePath,
1535 replacement: replacement.into(),
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::{
1542 CheckMode, Diagnostic, DiagnosticSelection, LintProfile, NamespaceSettings,
1543 WorkspaceReport, check_exit_code, parse_check_mode, parse_lint_profile, split_segments,
1544 };
1545
1546 #[test]
1547 fn splits_pascal_camel_snake_and_acronyms() {
1548 assert_eq!(split_segments("WhatEver"), vec!["What", "Ever"]);
1549 assert_eq!(split_segments("whatEver"), vec!["what", "Ever"]);
1550 assert_eq!(split_segments("what_ever"), vec!["what", "ever"]);
1551 assert_eq!(split_segments("HTTPServer"), vec!["HTTP", "Server"]);
1552 }
1553
1554 #[test]
1555 fn parses_check_modes() {
1556 assert_eq!(parse_check_mode("off"), Ok(CheckMode::Off));
1557 assert_eq!(parse_check_mode("warn"), Ok(CheckMode::Warn));
1558 assert_eq!(parse_check_mode("deny"), Ok(CheckMode::Deny));
1559 }
1560
1561 #[test]
1562 fn check_mode_supports_standard_parsing() {
1563 assert_eq!("off".parse::<CheckMode>(), Ok(CheckMode::Off));
1564 assert_eq!("warn".parse::<CheckMode>(), Ok(CheckMode::Warn));
1565 assert_eq!("deny".parse::<CheckMode>(), Ok(CheckMode::Deny));
1566 }
1567
1568 #[test]
1569 fn rejects_invalid_check_mode() {
1570 let err = parse_check_mode("strict").unwrap_err();
1571 assert!(err.contains("expected off|warn|deny"));
1572 }
1573
1574 #[test]
1575 fn lint_profile_supports_standard_parsing() {
1576 assert_eq!(parse_lint_profile("core"), Ok(LintProfile::Core));
1577 assert_eq!(parse_lint_profile("surface"), Ok(LintProfile::Surface));
1578 assert_eq!(parse_lint_profile("strict"), Ok(LintProfile::Strict));
1579 }
1580
1581 #[test]
1582 fn rejects_invalid_lint_profile() {
1583 let err = parse_lint_profile("default").unwrap_err();
1584 assert!(err.contains("expected core|surface|strict"));
1585 }
1586
1587 #[test]
1588 fn diagnostic_selection_supports_standard_parsing() {
1589 assert_eq!(
1590 "all".parse::<DiagnosticSelection>(),
1591 Ok(DiagnosticSelection::All)
1592 );
1593 assert_eq!(
1594 "policy".parse::<DiagnosticSelection>(),
1595 Ok(DiagnosticSelection::Policy)
1596 );
1597 assert_eq!(
1598 "advisory".parse::<DiagnosticSelection>(),
1599 Ok(DiagnosticSelection::Advisory)
1600 );
1601 }
1602
1603 #[test]
1604 fn rejects_invalid_diagnostic_selection() {
1605 let err = "warnings".parse::<DiagnosticSelection>().unwrap_err();
1606 assert!(err.contains("expected all|policy|advisory"));
1607 }
1608
1609 #[test]
1610 fn check_exit_code_follows_warn_and_deny_semantics() {
1611 let clean = WorkspaceReport {
1612 scanned_files: 1,
1613 files_with_violations: 0,
1614 diagnostics: Vec::new(),
1615 };
1616 assert_eq!(check_exit_code(&clean, CheckMode::Warn), 0);
1617 assert_eq!(check_exit_code(&clean, CheckMode::Deny), 0);
1618
1619 let with_policy = WorkspaceReport {
1620 scanned_files: 1,
1621 files_with_violations: 1,
1622 diagnostics: vec![Diagnostic::policy(None, None, "lint", "warning")],
1623 };
1624 assert_eq!(check_exit_code(&with_policy, CheckMode::Warn), 0);
1625 assert_eq!(check_exit_code(&with_policy, CheckMode::Deny), 2);
1626
1627 let with_error = WorkspaceReport {
1628 scanned_files: 1,
1629 files_with_violations: 1,
1630 diagnostics: vec![Diagnostic::error(None, None, "error")],
1631 };
1632 assert_eq!(check_exit_code(&with_error, CheckMode::Warn), 1);
1633 assert_eq!(check_exit_code(&with_error, CheckMode::Deny), 1);
1634 }
1635
1636 #[test]
1637 fn namespace_settings_defaults_cover_generic_nouns_and_weak_modules() {
1638 let settings = NamespaceSettings::default();
1639 assert!(settings.generic_nouns.contains("Repository"));
1640 assert!(settings.generic_nouns.contains("Id"));
1641 assert!(settings.generic_nouns.contains("Outcome"));
1642 assert!(settings.weak_modules.contains("storage"));
1643 assert!(settings.catch_all_modules.contains("helpers"));
1644 assert!(settings.organizational_modules.contains("error"));
1645 assert!(settings.organizational_modules.contains("request"));
1646 assert!(settings.organizational_modules.contains("response"));
1647 assert!(settings.namespace_preserving_modules.contains("email"));
1648 assert!(settings.namespace_preserving_modules.contains("components"));
1649 assert!(settings.namespace_preserving_modules.contains("partials"));
1650 assert!(settings.namespace_preserving_modules.contains("trace"));
1651 assert!(settings.namespace_preserving_modules.contains("write_back"));
1652 assert!(!settings.namespace_preserving_modules.contains("views"));
1653 assert!(!settings.namespace_preserving_modules.contains("handlers"));
1654 }
1655
1656 #[test]
1657 fn workspace_report_can_filter_policy_and_advisory_diagnostics() {
1658 let report = WorkspaceReport {
1659 scanned_files: 2,
1660 files_with_violations: 2,
1661 diagnostics: vec![
1662 Diagnostic::policy(Some("src/policy.rs".into()), Some(1), "policy", "policy"),
1663 Diagnostic::advisory(
1664 Some("src/advisory.rs".into()),
1665 Some(2),
1666 "advisory",
1667 "advisory",
1668 ),
1669 Diagnostic::error(Some("src/error.rs".into()), Some(3), "error"),
1670 ],
1671 };
1672
1673 let policy_only = report.filtered(DiagnosticSelection::Policy);
1674 assert_eq!(policy_only.files_with_violations, 2);
1675 assert_eq!(policy_only.error_count(), 1);
1676 assert_eq!(policy_only.policy_warning_count(), 1);
1677 assert_eq!(policy_only.advisory_warning_count(), 0);
1678
1679 let advisory_only = report.filtered(DiagnosticSelection::Advisory);
1680 assert_eq!(advisory_only.files_with_violations, 2);
1681 assert_eq!(advisory_only.error_count(), 1);
1682 assert_eq!(advisory_only.policy_warning_count(), 0);
1683 assert_eq!(advisory_only.advisory_warning_count(), 1);
1684 }
1685
1686 #[test]
1687 fn workspace_report_can_filter_diagnostics_by_profile() {
1688 let report = WorkspaceReport {
1689 scanned_files: 3,
1690 files_with_violations: 3,
1691 diagnostics: vec![
1692 Diagnostic::policy(
1693 Some("src/core.rs".into()),
1694 Some(1),
1695 "namespace_flat_use",
1696 "core",
1697 ),
1698 Diagnostic::policy(
1699 Some("src/surface.rs".into()),
1700 Some(2),
1701 "api_missing_parent_surface_export",
1702 "surface",
1703 ),
1704 Diagnostic::advisory(
1705 Some("src/strict.rs".into()),
1706 Some(3),
1707 "api_candidate_semantic_module",
1708 "strict",
1709 ),
1710 Diagnostic::error(Some("Cargo.toml".into()), None, "config"),
1711 ],
1712 };
1713
1714 let core = report.filtered_by_profile(LintProfile::Core);
1715 assert_eq!(core.files_with_violations, 2);
1716 assert_eq!(core.diagnostics.len(), 2);
1717 assert!(
1718 core.diagnostics
1719 .iter()
1720 .any(|diag| diag.code() == Some("namespace_flat_use"))
1721 );
1722 assert!(core.diagnostics.iter().any(|diag| diag.code().is_none()));
1723
1724 let surface = report.filtered_by_profile(LintProfile::Surface);
1725 assert_eq!(surface.files_with_violations, 3);
1726 assert_eq!(surface.diagnostics.len(), 3);
1727 assert!(
1728 surface
1729 .diagnostics
1730 .iter()
1731 .any(|diag| diag.code() == Some("api_missing_parent_surface_export"))
1732 );
1733 assert!(
1734 !surface
1735 .diagnostics
1736 .iter()
1737 .any(|diag| diag.code() == Some("api_candidate_semantic_module"))
1738 );
1739 }
1740}