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::{Deserialize, 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 fn from_diagnostics(scanned_files: usize, diagnostics: Vec<Diagnostic>) -> Self {
111 let files_with_violations = diagnostics
112 .iter()
113 .filter_map(|diag| diag.file.as_ref())
114 .collect::<BTreeSet<_>>()
115 .len();
116
117 Self {
118 scanned_files,
119 files_with_violations,
120 diagnostics,
121 }
122 }
123
124 pub fn error_count(&self) -> usize {
125 self.diagnostics
126 .iter()
127 .filter(|diag| diag.is_error())
128 .count()
129 }
130
131 pub fn warning_count(&self) -> usize {
132 self.diagnostics
133 .iter()
134 .filter(|diag| !diag.is_error())
135 .count()
136 }
137
138 pub fn policy_warning_count(&self) -> usize {
139 self.diagnostics
140 .iter()
141 .filter(|diag| diag.is_policy_warning())
142 .count()
143 }
144
145 pub fn advisory_warning_count(&self) -> usize {
146 self.diagnostics
147 .iter()
148 .filter(|diag| diag.is_advisory_warning())
149 .count()
150 }
151
152 pub fn policy_violation_count(&self) -> usize {
153 self.diagnostics
154 .iter()
155 .filter(|diag| diag.is_policy_violation())
156 .count()
157 }
158
159 pub fn filtered(&self, selection: DiagnosticSelection) -> Self {
160 let diagnostics = self
161 .diagnostics
162 .iter()
163 .filter(|diag| selection.includes(diag))
164 .cloned()
165 .collect::<Vec<_>>();
166 Self::from_diagnostics(self.scanned_files, diagnostics)
167 }
168
169 pub fn filtered_by_profile(&self, profile: LintProfile) -> Self {
170 let diagnostics = self
171 .diagnostics
172 .iter()
173 .filter(|diag| diag.included_in_profile(profile))
174 .cloned()
175 .collect::<Vec<_>>();
176 Self::from_diagnostics(self.scanned_files, diagnostics)
177 }
178
179 pub fn filtered_by_ignored_codes(&self, ignored_codes: &BTreeSet<String>) -> Self {
180 if ignored_codes.is_empty() {
181 return self.clone();
182 }
183
184 let diagnostics = self
185 .diagnostics
186 .iter()
187 .filter(|diag| !diag.code().is_some_and(|code| ignored_codes.contains(code)))
188 .cloned()
189 .collect::<Vec<_>>();
190 Self::from_diagnostics(self.scanned_files, diagnostics)
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CheckMode {
196 Off,
197 Warn,
198 Deny,
199}
200
201impl CheckMode {
202 pub fn parse(raw: &str) -> Result<Self, String> {
203 raw.parse()
204 }
205}
206
207impl std::str::FromStr for CheckMode {
208 type Err = String;
209
210 fn from_str(raw: &str) -> Result<Self, Self::Err> {
211 match raw {
212 "off" => Ok(Self::Off),
213 "warn" => Ok(Self::Warn),
214 "deny" => Ok(Self::Deny),
215 _ => Err(format!("invalid mode `{raw}`; expected off|warn|deny")),
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221pub struct CheckOutcome {
222 pub report: WorkspaceReport,
223 pub exit_code: u8,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Default)]
227pub struct ScanSettings {
228 pub include: Vec<String>,
229 pub exclude: Vec<String>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Default)]
233pub struct AnalysisSettings {
234 pub scan: ScanSettings,
235 pub profile: Option<LintProfile>,
236 pub ignored_diagnostic_codes: Vec<String>,
237 pub baseline: Option<PathBuf>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
241struct NamespaceSettings {
242 generic_nouns: BTreeSet<String>,
243 weak_modules: BTreeSet<String>,
244 catch_all_modules: BTreeSet<String>,
245 organizational_modules: BTreeSet<String>,
246 namespace_preserving_modules: BTreeSet<String>,
247 semantic_string_scalars: BTreeSet<String>,
248 semantic_numeric_scalars: BTreeSet<String>,
249 key_value_bag_names: BTreeSet<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253struct PackageSettings {
254 namespace: NamespaceSettings,
255 profile: Option<LintProfile>,
256 ignored_diagnostic_codes: BTreeSet<String>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260struct FileSettings {
261 namespace: NamespaceSettings,
262 profile: LintProfile,
263 ignored_diagnostic_codes: BTreeSet<String>,
264}
265
266#[derive(Clone, Copy)]
267struct FileResolutionContext<'a> {
268 workspace_defaults: &'a NamespaceSettings,
269 workspace_ignored_diagnostic_codes: &'a BTreeSet<String>,
270 repo_profile: Option<LintProfile>,
271 cli_profile: Option<LintProfile>,
272 cli_ignored_diagnostic_codes: &'a BTreeSet<String>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276struct DiagnosticBaseline {
277 version: u8,
278 diagnostics: Vec<BaselineDiagnostic>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
282struct BaselineDiagnostic {
283 code: String,
284 file: Option<String>,
285 line: Option<usize>,
286 message: String,
287}
288
289impl Default for NamespaceSettings {
290 fn default() -> Self {
291 Self {
292 generic_nouns: DEFAULT_GENERIC_NOUNS
293 .iter()
294 .map(|noun| (*noun).to_string())
295 .collect(),
296 weak_modules: DEFAULT_WEAK_MODULES
297 .iter()
298 .map(|module| (*module).to_string())
299 .collect(),
300 catch_all_modules: DEFAULT_CATCH_ALL_MODULES
301 .iter()
302 .map(|module| (*module).to_string())
303 .collect(),
304 organizational_modules: DEFAULT_ORGANIZATIONAL_MODULES
305 .iter()
306 .map(|module| (*module).to_string())
307 .collect(),
308 namespace_preserving_modules: DEFAULT_NAMESPACE_PRESERVING_MODULES
309 .iter()
310 .map(|module| (*module).to_string())
311 .collect(),
312 semantic_string_scalars: DEFAULT_SEMANTIC_STRING_SCALARS
313 .iter()
314 .map(|name| (*name).to_string())
315 .collect(),
316 semantic_numeric_scalars: DEFAULT_SEMANTIC_NUMERIC_SCALARS
317 .iter()
318 .map(|name| (*name).to_string())
319 .collect(),
320 key_value_bag_names: DEFAULT_KEY_VALUE_BAG_NAMES
321 .iter()
322 .map(|name| (*name).to_string())
323 .collect(),
324 }
325 }
326}
327
328pub fn parse_check_mode(raw: &str) -> Result<CheckMode, String> {
329 CheckMode::parse(raw)
330}
331
332pub fn parse_lint_profile(raw: &str) -> Result<LintProfile, String> {
333 raw.parse()
334}
335
336pub fn render_diagnostic_explanation(code: &str) -> Option<String> {
337 let info = diagnostic_code_info(code)?;
338 let mut rendered = format!(
339 "{code}\nprofile: {}\nsummary: {}",
340 info.profile.as_str(),
341 info.summary,
342 );
343
344 if let Some(details) = diagnostic_explanation_details(code) {
345 for detail in details {
346 let _ = write!(rendered, "\n{detail}");
347 }
348 }
349
350 let _ = write!(
351 rendered,
352 "\nsuppression: use `--ignore {code}` or `metadata.modum.ignored_diagnostic_codes = [\"{code}\"]` when the rule is not a fit for this repo or package."
353 );
354 let _ = write!(
355 rendered,
356 "\nbaseline: for large repos, write a baseline with `modum check --write-baseline .modum-baseline.json` and apply it with `modum check --baseline .modum-baseline.json` or `metadata.modum.baseline = \".modum-baseline.json\"`."
357 );
358
359 Some(rendered)
360}
361
362impl DiagnosticBaseline {
363 fn from_report(root: &Path, report: &WorkspaceReport) -> Self {
364 Self {
365 version: 1,
366 diagnostics: report
367 .diagnostics
368 .iter()
369 .filter_map(|diag| baseline_diagnostic_for_report(root, diag))
370 .collect(),
371 }
372 }
373}
374
375pub fn write_diagnostic_baseline(
376 root: &Path,
377 path: &Path,
378 report: &WorkspaceReport,
379) -> io::Result<usize> {
380 let baseline = DiagnosticBaseline::from_report(root, report);
381 let resolved_path = resolve_repo_relative_path(root, path);
382 if let Some(parent) = resolved_path.parent() {
383 fs::create_dir_all(parent)?;
384 }
385 let rendered = serde_json::to_string_pretty(&baseline)
386 .map_err(|err| io::Error::other(format!("failed to render baseline json: {err}")))?;
387 fs::write(&resolved_path, rendered)?;
388 Ok(baseline.diagnostics.len())
389}
390
391pub fn run_check(root: &Path, include_globs: &[String], mode: CheckMode) -> CheckOutcome {
392 run_check_with_scan_settings(
393 root,
394 &ScanSettings {
395 include: include_globs.to_vec(),
396 exclude: Vec::new(),
397 },
398 mode,
399 )
400}
401
402pub fn run_check_with_scan_settings(
403 root: &Path,
404 scan_settings: &ScanSettings,
405 mode: CheckMode,
406) -> CheckOutcome {
407 run_check_with_settings(
408 root,
409 &AnalysisSettings {
410 scan: scan_settings.clone(),
411 profile: None,
412 ignored_diagnostic_codes: Vec::new(),
413 baseline: None,
414 },
415 mode,
416 )
417}
418
419pub fn run_check_with_settings(
420 root: &Path,
421 settings: &AnalysisSettings,
422 mode: CheckMode,
423) -> CheckOutcome {
424 if mode == CheckMode::Off {
425 return CheckOutcome {
426 report: WorkspaceReport {
427 scanned_files: 0,
428 files_with_violations: 0,
429 diagnostics: Vec::new(),
430 },
431 exit_code: 0,
432 };
433 }
434
435 let report = analyze_workspace_with_settings(root, settings);
436 let exit_code = check_exit_code(&report, mode);
437 CheckOutcome { report, exit_code }
438}
439
440fn check_exit_code(report: &WorkspaceReport, mode: CheckMode) -> u8 {
441 if report.error_count() > 0 {
442 return 1;
443 }
444
445 if report.policy_violation_count() == 0 || mode == CheckMode::Warn {
446 0
447 } else {
448 2
449 }
450}
451
452pub fn analyze_file(path: &Path, src: &str) -> AnalysisResult {
453 analyze_file_with_settings(path, src, &NamespaceSettings::default())
454}
455
456fn analyze_file_with_settings(
457 path: &Path,
458 src: &str,
459 settings: &NamespaceSettings,
460) -> AnalysisResult {
461 let parsed = match syn::parse_file(src) {
462 Ok(file) => file,
463 Err(err) => {
464 return AnalysisResult {
465 diagnostics: vec![Diagnostic::error(
466 Some(path.to_path_buf()),
467 None,
468 format!("failed to parse rust file: {err}"),
469 )],
470 };
471 }
472 };
473
474 let mut result = AnalysisResult::empty();
475 result
476 .diagnostics
477 .extend(namespace::analyze_namespace_rules(path, &parsed, settings).diagnostics);
478 result
479 .diagnostics
480 .extend(api_shape::analyze_api_shape_rules(path, &parsed, settings).diagnostics);
481 result.diagnostics.sort();
482 result
483}
484
485pub fn analyze_workspace(root: &Path, include_globs: &[String]) -> WorkspaceReport {
486 analyze_workspace_with_scan_settings(
487 root,
488 &ScanSettings {
489 include: include_globs.to_vec(),
490 exclude: Vec::new(),
491 },
492 )
493}
494
495pub fn analyze_workspace_with_scan_settings(
496 root: &Path,
497 cli_scan_settings: &ScanSettings,
498) -> WorkspaceReport {
499 analyze_workspace_with_settings(
500 root,
501 &AnalysisSettings {
502 scan: cli_scan_settings.clone(),
503 profile: None,
504 ignored_diagnostic_codes: Vec::new(),
505 baseline: None,
506 },
507 )
508}
509
510pub fn analyze_workspace_with_settings(
511 root: &Path,
512 cli_settings: &AnalysisSettings,
513) -> WorkspaceReport {
514 let mut diagnostics = Vec::new();
515 let workspace_defaults = load_workspace_settings(root, &mut diagnostics);
516 let repo_profile = load_repo_profile(root, &mut diagnostics);
517 let workspace_ignored_diagnostic_codes =
518 load_repo_ignored_diagnostic_codes(root, &mut diagnostics);
519 let repo_baseline = load_repo_baseline_path(root, &mut diagnostics);
520 let repo_scan_settings = load_repo_scan_settings(root, &mut diagnostics);
521 let effective_scan_settings = effective_scan_settings(&repo_scan_settings, &cli_settings.scan);
522 let cli_ignored_diagnostic_codes = collect_valid_diagnostic_codes(
523 &cli_settings.ignored_diagnostic_codes,
524 None,
525 &mut diagnostics,
526 );
527 let rust_files = match collect_rust_files(
528 root,
529 &effective_scan_settings.include,
530 &effective_scan_settings.exclude,
531 ) {
532 Ok(files) => files,
533 Err(err) => {
534 diagnostics.push(Diagnostic::error(
535 None,
536 None,
537 format!("failed to discover rust files: {err}"),
538 ));
539 return WorkspaceReport::from_diagnostics(0, diagnostics);
540 }
541 };
542
543 if rust_files.is_empty() {
544 diagnostics.push(Diagnostic::warning(
545 None,
546 None,
547 "no Rust files were discovered; pass --include <path>... or run from a crate/workspace root",
548 ));
549 }
550
551 let mut files_with_violations = BTreeSet::new();
552 let mut package_cache = BTreeMap::new();
553
554 for file in &rust_files {
555 let src = match fs::read_to_string(file) {
556 Ok(src) => src,
557 Err(err) => {
558 diagnostics.push(Diagnostic::error(
559 Some(file.clone()),
560 None,
561 format!("failed to read file: {err}"),
562 ));
563 continue;
564 }
565 };
566
567 let settings = settings_for_file(
568 root,
569 file,
570 FileResolutionContext {
571 workspace_defaults: &workspace_defaults,
572 workspace_ignored_diagnostic_codes: &workspace_ignored_diagnostic_codes,
573 repo_profile,
574 cli_profile: cli_settings.profile,
575 cli_ignored_diagnostic_codes: &cli_ignored_diagnostic_codes,
576 },
577 &mut package_cache,
578 &mut diagnostics,
579 );
580 let mut analysis = analyze_file_with_settings(file, &src, &settings.namespace);
581 analysis.diagnostics.retain(|diag| {
582 diag.included_in_profile(settings.profile)
583 && !diag
584 .code()
585 .is_some_and(|code| settings.ignored_diagnostic_codes.contains(code))
586 });
587 if !analysis.diagnostics.is_empty() {
588 files_with_violations.insert(file.clone());
589 }
590 diagnostics.extend(analysis.diagnostics);
591 }
592
593 diagnostics.sort();
594 let mut report = WorkspaceReport {
595 scanned_files: rust_files.len(),
596 files_with_violations: files_with_violations.len(),
597 diagnostics,
598 };
599
600 let effective_baseline_path = cli_settings.baseline.clone().or(repo_baseline);
601 if let Some(baseline_path) = effective_baseline_path {
602 match load_diagnostic_baseline(root, &baseline_path, cli_settings.baseline.is_some()) {
603 Ok(Some(baseline)) => {
604 report = apply_diagnostic_baseline(root, &report, &baseline);
605 }
606 Ok(None) => {}
607 Err(err) => {
608 let mut diagnostics = report.diagnostics;
609 diagnostics.push(Diagnostic::error(
610 Some(resolve_repo_relative_path(root, &baseline_path)),
611 None,
612 err,
613 ));
614 diagnostics.sort();
615 report = WorkspaceReport::from_diagnostics(report.scanned_files, diagnostics);
616 }
617 }
618 }
619
620 report
621}
622
623fn effective_scan_settings(
624 repo_defaults: &ScanSettings,
625 cli_overrides: &ScanSettings,
626) -> ScanSettings {
627 let include = if cli_overrides.include.is_empty() {
628 repo_defaults.include.clone()
629 } else {
630 cli_overrides.include.clone()
631 };
632 let mut exclude = repo_defaults.exclude.clone();
633 exclude.extend(cli_overrides.exclude.iter().cloned());
634 ScanSettings { include, exclude }
635}
636
637fn load_workspace_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> NamespaceSettings {
638 let manifest_path = root.join("Cargo.toml");
639 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
640 return NamespaceSettings::default();
641 };
642
643 let manifest: toml::Value = match toml::from_str(&manifest_src) {
644 Ok(manifest) => manifest,
645 Err(err) => {
646 diagnostics.push(Diagnostic::error(
647 Some(manifest_path),
648 None,
649 format!("failed to parse Cargo.toml for modum settings: {err}"),
650 ));
651 return NamespaceSettings::default();
652 }
653 };
654
655 parse_settings_from_manifest(
656 manifest
657 .get("workspace")
658 .and_then(toml::Value::as_table)
659 .and_then(|workspace| workspace.get("metadata"))
660 .and_then(toml::Value::as_table)
661 .and_then(|metadata| metadata.get("modum")),
662 &NamespaceSettings::default(),
663 &manifest_path,
664 diagnostics,
665 )
666 .unwrap_or_default()
667}
668
669fn load_repo_scan_settings(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> ScanSettings {
670 let manifest_path = root.join("Cargo.toml");
671 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
672 return ScanSettings::default();
673 };
674
675 let manifest: toml::Value = match toml::from_str(&manifest_src) {
676 Ok(manifest) => manifest,
677 Err(err) => {
678 diagnostics.push(Diagnostic::error(
679 Some(manifest_path),
680 None,
681 format!("failed to parse Cargo.toml for modum settings: {err}"),
682 ));
683 return ScanSettings::default();
684 }
685 };
686
687 parse_scan_settings_from_manifest(
688 manifest
689 .get("workspace")
690 .and_then(toml::Value::as_table)
691 .and_then(|workspace| workspace.get("metadata"))
692 .and_then(toml::Value::as_table)
693 .and_then(|metadata| metadata.get("modum"))
694 .or_else(|| {
695 manifest
696 .get("package")
697 .and_then(toml::Value::as_table)
698 .and_then(|package| package.get("metadata"))
699 .and_then(toml::Value::as_table)
700 .and_then(|metadata| metadata.get("modum"))
701 }),
702 &manifest_path,
703 diagnostics,
704 )
705 .unwrap_or_default()
706}
707
708fn load_repo_profile(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> Option<LintProfile> {
709 let manifest_path = root.join("Cargo.toml");
710 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
711 return None;
712 };
713
714 let manifest: toml::Value = match toml::from_str(&manifest_src) {
715 Ok(manifest) => manifest,
716 Err(err) => {
717 diagnostics.push(Diagnostic::error(
718 Some(manifest_path),
719 None,
720 format!("failed to parse Cargo.toml for modum settings: {err}"),
721 ));
722 return None;
723 }
724 };
725
726 parse_profile_from_manifest(
727 manifest
728 .get("workspace")
729 .and_then(toml::Value::as_table)
730 .and_then(|workspace| workspace.get("metadata"))
731 .and_then(toml::Value::as_table)
732 .and_then(|metadata| metadata.get("modum"))
733 .or_else(|| {
734 manifest
735 .get("package")
736 .and_then(toml::Value::as_table)
737 .and_then(|package| package.get("metadata"))
738 .and_then(toml::Value::as_table)
739 .and_then(|metadata| metadata.get("modum"))
740 }),
741 &manifest_path,
742 diagnostics,
743 )
744}
745
746fn load_repo_ignored_diagnostic_codes(
747 root: &Path,
748 diagnostics: &mut Vec<Diagnostic>,
749) -> BTreeSet<String> {
750 let manifest_path = root.join("Cargo.toml");
751 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
752 return BTreeSet::new();
753 };
754
755 let manifest: toml::Value = match toml::from_str(&manifest_src) {
756 Ok(manifest) => manifest,
757 Err(err) => {
758 diagnostics.push(Diagnostic::error(
759 Some(manifest_path),
760 None,
761 format!("failed to parse Cargo.toml for modum settings: {err}"),
762 ));
763 return BTreeSet::new();
764 }
765 };
766
767 parse_ignored_diagnostic_codes_from_manifest(
768 manifest
769 .get("workspace")
770 .and_then(toml::Value::as_table)
771 .and_then(|workspace| workspace.get("metadata"))
772 .and_then(toml::Value::as_table)
773 .and_then(|metadata| metadata.get("modum"))
774 .or_else(|| {
775 manifest
776 .get("package")
777 .and_then(toml::Value::as_table)
778 .and_then(|package| package.get("metadata"))
779 .and_then(toml::Value::as_table)
780 .and_then(|metadata| metadata.get("modum"))
781 }),
782 &manifest_path,
783 diagnostics,
784 )
785 .unwrap_or_default()
786}
787
788fn load_repo_baseline_path(root: &Path, diagnostics: &mut Vec<Diagnostic>) -> Option<PathBuf> {
789 let manifest_path = root.join("Cargo.toml");
790 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
791 return None;
792 };
793
794 let manifest: toml::Value = match toml::from_str(&manifest_src) {
795 Ok(manifest) => manifest,
796 Err(err) => {
797 diagnostics.push(Diagnostic::error(
798 Some(manifest_path),
799 None,
800 format!("failed to parse Cargo.toml for modum settings: {err}"),
801 ));
802 return None;
803 }
804 };
805
806 parse_baseline_path_from_manifest(
807 manifest
808 .get("workspace")
809 .and_then(toml::Value::as_table)
810 .and_then(|workspace| workspace.get("metadata"))
811 .and_then(toml::Value::as_table)
812 .and_then(|metadata| metadata.get("modum"))
813 .or_else(|| {
814 manifest
815 .get("package")
816 .and_then(toml::Value::as_table)
817 .and_then(|package| package.get("metadata"))
818 .and_then(toml::Value::as_table)
819 .and_then(|metadata| metadata.get("modum"))
820 }),
821 &manifest_path,
822 diagnostics,
823 )
824}
825
826fn settings_for_file(
827 root: &Path,
828 file: &Path,
829 context: FileResolutionContext<'_>,
830 cache: &mut BTreeMap<PathBuf, PackageSettings>,
831 diagnostics: &mut Vec<Diagnostic>,
832) -> FileSettings {
833 let Some(package_root) = find_package_root(root, file) else {
834 let mut ignored_diagnostic_codes = context.workspace_ignored_diagnostic_codes.clone();
835 ignored_diagnostic_codes.extend(context.cli_ignored_diagnostic_codes.iter().cloned());
836 return FileSettings {
837 namespace: context.workspace_defaults.clone(),
838 profile: resolve_profile(context.cli_profile, None, context.repo_profile),
839 ignored_diagnostic_codes,
840 };
841 };
842
843 if let Some(settings) = cache.get(&package_root) {
844 let mut ignored_diagnostic_codes = settings.ignored_diagnostic_codes.clone();
845 ignored_diagnostic_codes.extend(context.cli_ignored_diagnostic_codes.iter().cloned());
846 return FileSettings {
847 namespace: settings.namespace.clone(),
848 profile: resolve_profile(context.cli_profile, settings.profile, context.repo_profile),
849 ignored_diagnostic_codes,
850 };
851 }
852
853 let settings = load_package_settings(
854 &package_root,
855 context.workspace_defaults,
856 context.workspace_ignored_diagnostic_codes,
857 diagnostics,
858 );
859 cache.insert(package_root, settings.clone());
860 let mut ignored_diagnostic_codes = settings.ignored_diagnostic_codes.clone();
861 ignored_diagnostic_codes.extend(context.cli_ignored_diagnostic_codes.iter().cloned());
862 FileSettings {
863 namespace: settings.namespace,
864 profile: resolve_profile(context.cli_profile, settings.profile, context.repo_profile),
865 ignored_diagnostic_codes,
866 }
867}
868
869fn load_package_settings(
870 root: &Path,
871 workspace_defaults: &NamespaceSettings,
872 workspace_ignored_diagnostic_codes: &BTreeSet<String>,
873 diagnostics: &mut Vec<Diagnostic>,
874) -> PackageSettings {
875 let manifest_path = root.join("Cargo.toml");
876 let Ok(manifest_src) = fs::read_to_string(&manifest_path) else {
877 return PackageSettings {
878 namespace: workspace_defaults.clone(),
879 profile: None,
880 ignored_diagnostic_codes: workspace_ignored_diagnostic_codes.clone(),
881 };
882 };
883
884 let manifest = match toml::from_str::<toml::Value>(&manifest_src) {
885 Ok(manifest) => manifest,
886 Err(err) => {
887 diagnostics.push(Diagnostic::error(
888 Some(manifest_path),
889 None,
890 format!("failed to parse Cargo.toml for modum settings: {err}"),
891 ));
892 return PackageSettings {
893 namespace: workspace_defaults.clone(),
894 profile: None,
895 ignored_diagnostic_codes: workspace_ignored_diagnostic_codes.clone(),
896 };
897 }
898 };
899
900 let metadata = manifest
901 .get("package")
902 .and_then(toml::Value::as_table)
903 .and_then(|package| package.get("metadata"))
904 .and_then(toml::Value::as_table)
905 .and_then(|metadata| metadata.get("modum"));
906
907 let namespace =
908 parse_settings_from_manifest(metadata, workspace_defaults, &manifest_path, diagnostics)
909 .unwrap_or_else(|| workspace_defaults.clone());
910 let profile = parse_profile_from_manifest(metadata, &manifest_path, diagnostics);
911 let mut ignored_diagnostic_codes = workspace_ignored_diagnostic_codes.clone();
912 if let Some(local_codes) =
913 parse_ignored_diagnostic_codes_from_manifest(metadata, &manifest_path, diagnostics)
914 {
915 ignored_diagnostic_codes.extend(local_codes);
916 }
917
918 PackageSettings {
919 namespace,
920 profile,
921 ignored_diagnostic_codes,
922 }
923}
924
925fn resolve_profile(
926 cli_profile: Option<LintProfile>,
927 package_profile: Option<LintProfile>,
928 repo_profile: Option<LintProfile>,
929) -> LintProfile {
930 cli_profile
931 .or(package_profile)
932 .or(repo_profile)
933 .unwrap_or_default()
934}
935
936fn parse_settings_from_manifest(
937 value: Option<&toml::Value>,
938 base: &NamespaceSettings,
939 manifest_path: &Path,
940 diagnostics: &mut Vec<Diagnostic>,
941) -> Option<NamespaceSettings> {
942 let table = value?.as_table()?;
943 let mut settings = base.clone();
944
945 if let Some(values) = parse_string_set_field(table, "generic_nouns", manifest_path, diagnostics)
946 {
947 settings.generic_nouns = values;
948 }
949 if let Some(values) = parse_string_set_field(table, "weak_modules", manifest_path, diagnostics)
950 {
951 settings.weak_modules = values;
952 }
953 if let Some(values) =
954 parse_string_set_field(table, "catch_all_modules", manifest_path, diagnostics)
955 {
956 settings.catch_all_modules = values;
957 }
958 if let Some(values) =
959 parse_string_set_field(table, "organizational_modules", manifest_path, diagnostics)
960 {
961 settings.organizational_modules = values;
962 }
963 if let Some(values) = parse_normalized_string_set_field(
964 table,
965 "namespace_preserving_modules",
966 manifest_path,
967 diagnostics,
968 ) {
969 settings.namespace_preserving_modules = values;
970 }
971 apply_token_family_overrides(
972 &mut settings.namespace_preserving_modules,
973 table,
974 "extra_namespace_preserving_modules",
975 "ignored_namespace_preserving_modules",
976 manifest_path,
977 diagnostics,
978 );
979 apply_token_family_overrides(
980 &mut settings.semantic_string_scalars,
981 table,
982 "extra_semantic_string_scalars",
983 "ignored_semantic_string_scalars",
984 manifest_path,
985 diagnostics,
986 );
987 apply_token_family_overrides(
988 &mut settings.semantic_numeric_scalars,
989 table,
990 "extra_semantic_numeric_scalars",
991 "ignored_semantic_numeric_scalars",
992 manifest_path,
993 diagnostics,
994 );
995 apply_token_family_overrides(
996 &mut settings.key_value_bag_names,
997 table,
998 "extra_key_value_bag_names",
999 "ignored_key_value_bag_names",
1000 manifest_path,
1001 diagnostics,
1002 );
1003
1004 Some(settings)
1005}
1006
1007fn parse_scan_settings_from_manifest(
1008 value: Option<&toml::Value>,
1009 manifest_path: &Path,
1010 diagnostics: &mut Vec<Diagnostic>,
1011) -> Option<ScanSettings> {
1012 let table = value?.as_table()?;
1013 let mut settings = ScanSettings::default();
1014
1015 if let Some(values) = parse_string_list_field(table, "include", manifest_path, diagnostics) {
1016 settings.include = values;
1017 }
1018 if let Some(values) = parse_string_list_field(table, "exclude", manifest_path, diagnostics) {
1019 settings.exclude = values;
1020 }
1021
1022 Some(settings)
1023}
1024
1025fn parse_profile_from_manifest(
1026 value: Option<&toml::Value>,
1027 manifest_path: &Path,
1028 diagnostics: &mut Vec<Diagnostic>,
1029) -> Option<LintProfile> {
1030 let table = value?.as_table()?;
1031 let raw = parse_string_field(table, "profile", manifest_path, diagnostics)?;
1032 match raw.parse() {
1033 Ok(profile) => Some(profile),
1034 Err(err) => {
1035 diagnostics.push(Diagnostic::error(
1036 Some(manifest_path.to_path_buf()),
1037 None,
1038 format!("`metadata.modum.profile` {err}"),
1039 ));
1040 None
1041 }
1042 }
1043}
1044
1045fn parse_ignored_diagnostic_codes_from_manifest(
1046 value: Option<&toml::Value>,
1047 manifest_path: &Path,
1048 diagnostics: &mut Vec<Diagnostic>,
1049) -> Option<BTreeSet<String>> {
1050 let table = value?.as_table()?;
1051 let values = parse_string_values_field(
1052 table,
1053 "ignored_diagnostic_codes",
1054 manifest_path,
1055 diagnostics,
1056 )?;
1057 let key_prefix = "`metadata.modum.ignored_diagnostic_codes";
1058 Some(collect_valid_diagnostic_codes(
1059 &values,
1060 Some(key_prefix),
1061 diagnostics,
1062 ))
1063}
1064
1065fn parse_baseline_path_from_manifest(
1066 value: Option<&toml::Value>,
1067 manifest_path: &Path,
1068 diagnostics: &mut Vec<Diagnostic>,
1069) -> Option<PathBuf> {
1070 let table = value?.as_table()?;
1071 parse_string_field(table, "baseline", manifest_path, diagnostics).map(PathBuf::from)
1072}
1073
1074fn parse_string_field(
1075 table: &toml::value::Table,
1076 key: &str,
1077 manifest_path: &Path,
1078 diagnostics: &mut Vec<Diagnostic>,
1079) -> Option<String> {
1080 let value = table.get(key)?;
1081 let Some(value) = value.as_str() else {
1082 diagnostics.push(Diagnostic::error(
1083 Some(manifest_path.to_path_buf()),
1084 None,
1085 format!("`metadata.modum.{key}` must be a string"),
1086 ));
1087 return None;
1088 };
1089
1090 Some(value.to_string())
1091}
1092
1093fn parse_string_set_field(
1094 table: &toml::value::Table,
1095 key: &str,
1096 manifest_path: &Path,
1097 diagnostics: &mut Vec<Diagnostic>,
1098) -> Option<BTreeSet<String>> {
1099 Some(
1100 parse_string_values_field(table, key, manifest_path, diagnostics)?
1101 .into_iter()
1102 .collect(),
1103 )
1104}
1105
1106fn parse_string_list_field(
1107 table: &toml::value::Table,
1108 key: &str,
1109 manifest_path: &Path,
1110 diagnostics: &mut Vec<Diagnostic>,
1111) -> Option<Vec<String>> {
1112 parse_string_values_field(table, key, manifest_path, diagnostics)
1113}
1114
1115fn parse_string_values_field(
1116 table: &toml::value::Table,
1117 key: &str,
1118 manifest_path: &Path,
1119 diagnostics: &mut Vec<Diagnostic>,
1120) -> Option<Vec<String>> {
1121 let value = table.get(key)?;
1122 let Some(array) = value.as_array() else {
1123 diagnostics.push(Diagnostic::error(
1124 Some(manifest_path.to_path_buf()),
1125 None,
1126 format!("`metadata.modum.{key}` must be an array of strings"),
1127 ));
1128 return None;
1129 };
1130
1131 let mut values = Vec::with_capacity(array.len());
1132 for (index, value) in array.iter().enumerate() {
1133 let Some(value) = value.as_str() else {
1134 diagnostics.push(Diagnostic::error(
1135 Some(manifest_path.to_path_buf()),
1136 None,
1137 format!("`metadata.modum.{key}[{index}]` must be a string"),
1138 ));
1139 return None;
1140 };
1141 values.push(value.to_string());
1142 }
1143
1144 Some(values)
1145}
1146
1147fn parse_normalized_string_set_field(
1148 table: &toml::value::Table,
1149 key: &str,
1150 manifest_path: &Path,
1151 diagnostics: &mut Vec<Diagnostic>,
1152) -> Option<BTreeSet<String>> {
1153 Some(
1154 parse_string_values_field(table, key, manifest_path, diagnostics)?
1155 .into_iter()
1156 .map(|value| normalize_segment(&value))
1157 .collect(),
1158 )
1159}
1160
1161fn apply_token_family_overrides(
1162 target: &mut BTreeSet<String>,
1163 table: &toml::value::Table,
1164 extra_key: &str,
1165 ignored_key: &str,
1166 manifest_path: &Path,
1167 diagnostics: &mut Vec<Diagnostic>,
1168) {
1169 if let Some(values) =
1170 parse_normalized_string_set_field(table, extra_key, manifest_path, diagnostics)
1171 {
1172 target.extend(values);
1173 }
1174 if let Some(values) =
1175 parse_normalized_string_set_field(table, ignored_key, manifest_path, diagnostics)
1176 {
1177 for value in values {
1178 target.remove(&value);
1179 }
1180 }
1181}
1182
1183fn collect_valid_diagnostic_codes(
1184 values: &[String],
1185 metadata_key_prefix: Option<&str>,
1186 diagnostics: &mut Vec<Diagnostic>,
1187) -> BTreeSet<String> {
1188 let mut codes = BTreeSet::new();
1189
1190 for (index, code) in values.iter().enumerate() {
1191 if diagnostic_code_info(code).is_none() {
1192 let message = if let Some(prefix) = metadata_key_prefix {
1193 format!("{prefix}[{index}]` unknown diagnostic code `{code}`")
1194 } else {
1195 format!("unknown diagnostic code `{code}`")
1196 };
1197 diagnostics.push(Diagnostic::error(None, None, message));
1198 continue;
1199 }
1200 codes.insert(code.clone());
1201 }
1202
1203 codes
1204}
1205
1206fn load_diagnostic_baseline(
1207 root: &Path,
1208 path: &Path,
1209 required: bool,
1210) -> Result<Option<DiagnosticBaseline>, String> {
1211 let resolved_path = resolve_repo_relative_path(root, path);
1212 let baseline_src = match fs::read_to_string(&resolved_path) {
1213 Ok(src) => src,
1214 Err(err) if err.kind() == io::ErrorKind::NotFound && !required => return Ok(None),
1215 Err(err) => {
1216 return Err(format!(
1217 "failed to read baseline {}: {err}",
1218 resolved_path.display()
1219 ));
1220 }
1221 };
1222
1223 let baseline: DiagnosticBaseline = serde_json::from_str(&baseline_src).map_err(|err| {
1224 format!(
1225 "failed to parse baseline {}: {err}",
1226 resolved_path.display()
1227 )
1228 })?;
1229 if baseline.version != 1 {
1230 return Err(format!(
1231 "unsupported baseline version {} in {}",
1232 baseline.version,
1233 resolved_path.display()
1234 ));
1235 }
1236
1237 Ok(Some(baseline))
1238}
1239
1240fn apply_diagnostic_baseline(
1241 root: &Path,
1242 report: &WorkspaceReport,
1243 baseline: &DiagnosticBaseline,
1244) -> WorkspaceReport {
1245 let mut remaining = BTreeMap::<(String, Option<String>, String), usize>::new();
1246 for diagnostic in &baseline.diagnostics {
1247 let key = (
1248 diagnostic.code.clone(),
1249 diagnostic.file.clone(),
1250 diagnostic.message.clone(),
1251 );
1252 *remaining.entry(key).or_default() += 1;
1253 }
1254
1255 let diagnostics = report
1256 .diagnostics
1257 .iter()
1258 .filter(|diag| {
1259 let Some(key) = baseline_match_key(root, diag) else {
1260 return true;
1261 };
1262 let Some(count) = remaining.get_mut(&key) else {
1263 return true;
1264 };
1265 if *count == 0 {
1266 return true;
1267 }
1268 *count -= 1;
1269 false
1270 })
1271 .cloned()
1272 .collect::<Vec<_>>();
1273
1274 WorkspaceReport::from_diagnostics(report.scanned_files, diagnostics)
1275}
1276
1277fn baseline_diagnostic_for_report(root: &Path, diag: &Diagnostic) -> Option<BaselineDiagnostic> {
1278 Some(BaselineDiagnostic {
1279 code: diag.code()?.to_string(),
1280 file: diag
1281 .file
1282 .as_ref()
1283 .map(|file| render_relative_path(root, file)),
1284 line: diag.line,
1285 message: diag.message.clone(),
1286 })
1287}
1288
1289fn baseline_match_key(root: &Path, diag: &Diagnostic) -> Option<(String, Option<String>, String)> {
1290 Some((
1291 diag.code()?.to_string(),
1292 diag.file
1293 .as_ref()
1294 .map(|file| render_relative_path(root, file)),
1295 diag.message.clone(),
1296 ))
1297}
1298
1299fn resolve_repo_relative_path(root: &Path, path: &Path) -> PathBuf {
1300 if path.is_absolute() {
1301 path.to_path_buf()
1302 } else {
1303 root.join(path)
1304 }
1305}
1306
1307fn render_relative_path(root: &Path, path: &Path) -> String {
1308 path.strip_prefix(root)
1309 .unwrap_or(path)
1310 .to_string_lossy()
1311 .replace('\\', "/")
1312}
1313
1314fn diagnostic_explanation_details(code: &str) -> Option<&'static [&'static str]> {
1315 match code {
1316 "namespace_prelude_glob_import" => Some(&[
1317 "why: prelude globs make it harder to see which module gives a name its meaning.",
1318 "typical fixes: import the specific items you need or keep the preserving module visible at call sites.",
1319 ]),
1320 "namespace_flat_use_preserve_module" | "namespace_flat_pub_use_preserve_module" => Some(&[
1321 "why: flattening imports from modules like `http`, `page`, or `components` can erase the part of the path that explains the item's role.",
1322 "typical fixes: keep the module qualifier visible at the call site or re-export surface.",
1323 "repo tuning: use `metadata.modum.extra_namespace_preserving_modules` or `metadata.modum.ignored_namespace_preserving_modules` to adjust which modules should stay visible.",
1324 ]),
1325 "namespace_glob_preserve_module" => Some(&[
1326 "why: broad globs from modules like `http`, `error`, or `query` erase context that often helps readers scan call sites.",
1327 "typical fixes: import only the concrete items you need or keep the module qualifier in local code.",
1328 "repo tuning: use `metadata.modum.extra_namespace_preserving_modules` or `metadata.modum.ignored_namespace_preserving_modules` to adjust which modules should stay visible.",
1329 ]),
1330 "api_anyhow_error_surface" => Some(&[
1331 "why: `anyhow` works well internally, but caller-facing boundaries usually read better when the crate owns the error type and variants.",
1332 "typical fixes: return a crate-owned error enum or newtype and convert internal failures into that boundary type.",
1333 ]),
1334 "api_string_error_surface" => Some(&[
1335 "why: raw string errors lose structure, variant names, and machine-readable context at the boundary.",
1336 "typical fixes: model the boundary error as an enum, a focused struct, or another typed error value with named data.",
1337 ]),
1338 "api_semantic_string_scalar" => Some(&[
1339 "why: names like `email`, `url`, `path`, or `locale` usually carry domain rules that a plain `String` cannot express.",
1340 "typical fixes: parse at the boundary into a domain newtype or another focused typed value.",
1341 "repo tuning: use `metadata.modum.extra_semantic_string_scalars` or `metadata.modum.ignored_semantic_string_scalars` to adjust the token family.",
1342 ]),
1343 "api_semantic_numeric_scalar" => Some(&[
1344 "why: names like `duration`, `timestamp`, or `port` often want units or domain semantics, not a bare integer.",
1345 "typical fixes: use a typed duration, timestamp, port, or small domain newtype at the boundary.",
1346 "repo tuning: use `metadata.modum.extra_semantic_numeric_scalars` or `metadata.modum.ignored_semantic_numeric_scalars` to adjust the token family.",
1347 ]),
1348 "api_raw_key_value_bag" => Some(&[
1349 "why: bags like `metadata`, `headers`, or `params` often accrete hidden contracts that are easier to understand once they are typed.",
1350 "typical fixes: introduce a focused options struct, metadata type, or dedicated collection wrapper.",
1351 "repo tuning: use `metadata.modum.extra_key_value_bag_names` or `metadata.modum.ignored_key_value_bag_names` to adjust the token family.",
1352 ]),
1353 "callsite_maybe_some" => Some(&[
1354 "why: `maybe_*` setters are most useful when the caller already has an `Option<_>` to forward; wrapping a concrete value in `Some(...)` throws away that distinction.",
1355 "typical fixes: call the non-`maybe_` setter when you already have a value, or keep the `Option<_>` intact and pass that directly.",
1356 ]),
1357 "api_boolean_flag_cluster" => Some(&[
1358 "why: several booleans together usually encode modes or policy choices that are easier to name explicitly.",
1359 "typical fixes: group the behavior into a typed options struct, an enum, or a smaller decision object.",
1360 ]),
1361 "api_manual_flag_set" => Some(&[
1362 "why: parallel flag constants and repeated raw bitmask checks usually mean the boundary is modeling a flags type by hand.",
1363 "typical fixes: introduce a focused typed flags surface or small domain wrapper instead of exposing raw integer masks.",
1364 ]),
1365 "api_raw_id_surface" => Some(&[
1366 "why: ids often carry validation, formatting, or cross-system meaning that is easy to lose when they stay as bare strings or integers.",
1367 "typical fixes: introduce a small id newtype and parse or validate at the boundary.",
1368 ]),
1369 _ => None,
1370 }
1371}
1372
1373fn find_package_root(root: &Path, file: &Path) -> Option<PathBuf> {
1374 for ancestor in file.ancestors().skip(1) {
1375 let manifest_path = ancestor.join("Cargo.toml");
1376 if manifest_path.is_file()
1377 && let Ok(manifest_src) = fs::read_to_string(&manifest_path)
1378 && let Ok(manifest) = toml::from_str::<toml::Value>(&manifest_src)
1379 && manifest.get("package").is_some_and(toml::Value::is_table)
1380 {
1381 return Some(ancestor.to_path_buf());
1382 }
1383 if ancestor == root {
1384 break;
1385 }
1386 }
1387 None
1388}
1389
1390pub fn render_pretty_report(report: &WorkspaceReport) -> String {
1391 render_pretty_report_with_selection(report, DiagnosticSelection::All)
1392}
1393
1394pub fn render_pretty_report_with_selection(
1395 report: &WorkspaceReport,
1396 selection: DiagnosticSelection,
1397) -> String {
1398 let filtered = report.filtered(selection);
1399 let mut out = String::new();
1400
1401 let _ = writeln!(&mut out, "modum lint report");
1402 let _ = writeln!(&mut out, "files scanned: {}", filtered.scanned_files);
1403 let _ = writeln!(
1404 &mut out,
1405 "files with violations: {}",
1406 filtered.files_with_violations
1407 );
1408 let _ = writeln!(
1409 &mut out,
1410 "diagnostics: {} error(s), {} policy warning(s), {} advisory warning(s)",
1411 filtered.error_count(),
1412 filtered.policy_warning_count(),
1413 filtered.advisory_warning_count()
1414 );
1415 if let Some(selection_label) = selection.report_label() {
1416 let _ = writeln!(
1417 &mut out,
1418 "showing: {selection_label} (exit code still reflects the full report)"
1419 );
1420 }
1421 if filtered.policy_violation_count() > 0 {
1422 let _ = writeln!(
1423 &mut out,
1424 "policy violations: {}",
1425 filtered.policy_violation_count()
1426 );
1427 }
1428 if filtered.advisory_warning_count() > 0 {
1429 let _ = writeln!(
1430 &mut out,
1431 "advisories: {}",
1432 filtered.advisory_warning_count()
1433 );
1434 }
1435
1436 if !filtered.diagnostics.is_empty() {
1437 let _ = writeln!(&mut out);
1438 render_diagnostic_section(
1439 &mut out,
1440 "Errors:",
1441 filtered.diagnostics.iter().filter(|diag| diag.is_error()),
1442 );
1443 render_diagnostic_section(
1444 &mut out,
1445 "Policy Diagnostics:",
1446 filtered
1447 .diagnostics
1448 .iter()
1449 .filter(|diag| diag.is_policy_warning()),
1450 );
1451 render_diagnostic_section(
1452 &mut out,
1453 "Advisory Diagnostics:",
1454 filtered
1455 .diagnostics
1456 .iter()
1457 .filter(|diag| diag.is_advisory_warning()),
1458 );
1459 }
1460
1461 out
1462}
1463
1464fn render_diagnostic_section<'a>(
1465 out: &mut String,
1466 title: &str,
1467 diagnostics: impl Iterator<Item = &'a Diagnostic>,
1468) {
1469 let diagnostics = diagnostics.collect::<Vec<_>>();
1470 if diagnostics.is_empty() {
1471 return;
1472 }
1473
1474 let _ = writeln!(out, "{title}");
1475 for diag in diagnostics {
1476 let level = match diag.level() {
1477 DiagnosticLevel::Warning => "warning",
1478 DiagnosticLevel::Error => "error",
1479 };
1480 let code = match (diag.code(), diag.profile()) {
1481 (Some(code), Some(profile)) => format!(" ({code}, {})", profile.as_str()),
1482 (Some(code), None) => format!(" ({code})"),
1483 (None, _) => String::new(),
1484 };
1485 let fix = diag
1486 .fix
1487 .as_ref()
1488 .map(|fix| format!(" [fix: {}]", fix.replacement))
1489 .unwrap_or_default();
1490 match (&diag.file, diag.line) {
1491 (Some(file), Some(line)) => {
1492 let _ = writeln!(
1493 out,
1494 "- [{level}{code}] {}:{line}: {}{fix}",
1495 file.display(),
1496 diag.message
1497 );
1498 }
1499 (Some(file), None) => {
1500 let _ = writeln!(
1501 out,
1502 "- [{level}{code}] {}: {}{fix}",
1503 file.display(),
1504 diag.message
1505 );
1506 }
1507 (None, _) => {
1508 let _ = writeln!(out, "- [{level}{code}] {}{fix}", diag.message);
1509 }
1510 }
1511 }
1512 let _ = writeln!(out);
1513}
1514
1515fn collect_rust_files(
1516 root: &Path,
1517 include_globs: &[String],
1518 exclude_globs: &[String],
1519) -> io::Result<Vec<PathBuf>> {
1520 let mut files = BTreeSet::new();
1521 if include_globs.is_empty() {
1522 for scan_root in collect_default_scan_roots(root)? {
1523 collect_rust_files_in_dir(&scan_root, &mut files);
1524 }
1525 } else {
1526 for entry in include_globs {
1527 collect_rust_files_for_entry(root, entry, &mut files)?;
1528 }
1529 }
1530
1531 let mut filtered = Vec::with_capacity(files.len());
1532 for path in files {
1533 if !is_excluded_path(root, &path, exclude_globs)? {
1534 filtered.push(path);
1535 }
1536 }
1537
1538 Ok(filtered)
1539}
1540
1541fn collect_rust_files_for_entry(
1542 root: &Path,
1543 entry: &str,
1544 files: &mut BTreeSet<PathBuf>,
1545) -> io::Result<()> {
1546 let candidate = root.join(entry);
1547 if !contains_glob_meta(entry) {
1548 if candidate.is_file() && is_rust_file(&candidate) {
1549 files.insert(candidate);
1550 } else if candidate.is_dir() {
1551 collect_rust_files_in_dir(&candidate, files);
1552 }
1553 return Ok(());
1554 }
1555
1556 let escaped_root = Pattern::escape(&root.to_string_lossy());
1557 let normalized_pattern = entry.replace('\\', "/");
1558 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
1559 let matches = glob(&full_pattern).map_err(|err| {
1560 io::Error::new(
1561 io::ErrorKind::InvalidInput,
1562 format!("invalid include pattern `{entry}`: {err}"),
1563 )
1564 })?;
1565
1566 for matched in matches {
1567 let path = matched
1568 .map_err(|err| io::Error::other(format!("failed to expand `{entry}`: {err}")))?;
1569 if path.is_file() && is_rust_file(&path) {
1570 files.insert(path);
1571 } else if path.is_dir() {
1572 collect_rust_files_in_dir(&path, files);
1573 }
1574 }
1575
1576 Ok(())
1577}
1578
1579fn is_excluded_path(root: &Path, path: &Path, exclude_globs: &[String]) -> io::Result<bool> {
1580 if exclude_globs.is_empty() {
1581 return Ok(false);
1582 }
1583
1584 let relative = path
1585 .strip_prefix(root)
1586 .unwrap_or(path)
1587 .to_string_lossy()
1588 .replace('\\', "/");
1589 for pattern in exclude_globs {
1590 if contains_glob_meta(pattern) {
1591 let matcher = Pattern::new(pattern).map_err(|err| {
1592 io::Error::new(
1593 io::ErrorKind::InvalidInput,
1594 format!("invalid exclude pattern `{pattern}`: {err}"),
1595 )
1596 })?;
1597 if matcher.matches(&relative) {
1598 return Ok(true);
1599 }
1600 continue;
1601 }
1602
1603 let normalized = pattern.trim_end_matches('/').replace('\\', "/");
1604 if relative == normalized || relative.starts_with(&format!("{normalized}/")) {
1605 return Ok(true);
1606 }
1607 }
1608 Ok(false)
1609}
1610
1611fn collect_default_scan_roots(root: &Path) -> io::Result<Vec<PathBuf>> {
1612 let mut scan_roots = BTreeSet::new();
1613 let manifest_path = root.join("Cargo.toml");
1614
1615 if !manifest_path.is_file() {
1616 add_src_root(root, &mut scan_roots);
1617 return Ok(scan_roots.into_iter().collect());
1618 }
1619
1620 let manifest_src = fs::read_to_string(&manifest_path)?;
1621 let manifest: toml::Value = toml::from_str(&manifest_src).map_err(|err| {
1622 io::Error::new(
1623 io::ErrorKind::InvalidData,
1624 format!("failed to parse {}: {err}", manifest_path.display()),
1625 )
1626 })?;
1627
1628 let root_is_package = manifest.get("package").is_some_and(toml::Value::is_table);
1629 if root_is_package {
1630 add_src_root(root, &mut scan_roots);
1631 }
1632
1633 if let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) {
1634 let excluded = parse_workspace_patterns(workspace.get("exclude"));
1635 for member_pattern in parse_workspace_patterns(workspace.get("members")) {
1636 for member_root in resolve_workspace_member_pattern(root, &member_pattern)? {
1637 if is_excluded_member(root, &member_root, &excluded)? {
1638 continue;
1639 }
1640 add_src_root(&member_root, &mut scan_roots);
1641 }
1642 }
1643 } else if !root_is_package {
1644 add_src_root(root, &mut scan_roots);
1645 }
1646
1647 Ok(scan_roots.into_iter().collect())
1648}
1649
1650fn parse_workspace_patterns(value: Option<&toml::Value>) -> Vec<String> {
1651 value
1652 .and_then(toml::Value::as_array)
1653 .into_iter()
1654 .flatten()
1655 .filter_map(toml::Value::as_str)
1656 .map(std::string::ToString::to_string)
1657 .collect()
1658}
1659
1660fn resolve_workspace_member_pattern(root: &Path, pattern: &str) -> io::Result<Vec<PathBuf>> {
1661 let candidate = root.join(pattern);
1662 if !contains_glob_meta(pattern) {
1663 if candidate.is_dir() {
1664 return Ok(vec![candidate]);
1665 }
1666 if candidate
1667 .file_name()
1668 .is_some_and(|name| name == "Cargo.toml")
1669 && let Some(parent) = candidate.parent()
1670 {
1671 return Ok(vec![parent.to_path_buf()]);
1672 }
1673 return Ok(Vec::new());
1674 }
1675
1676 let escaped_root = Pattern::escape(&root.to_string_lossy());
1677 let normalized_pattern = pattern.replace('\\', "/");
1678 let full_pattern = format!("{escaped_root}/{normalized_pattern}");
1679 let mut paths = Vec::new();
1680 let matches = glob(&full_pattern).map_err(|err| {
1681 io::Error::new(
1682 io::ErrorKind::InvalidInput,
1683 format!("invalid workspace member pattern `{pattern}`: {err}"),
1684 )
1685 })?;
1686
1687 for entry in matches {
1688 let path = entry
1689 .map_err(|err| io::Error::other(format!("failed to expand `{pattern}`: {err}")))?;
1690 if path.is_dir() {
1691 paths.push(path);
1692 continue;
1693 }
1694 if path.file_name().is_some_and(|name| name == "Cargo.toml")
1695 && let Some(parent) = path.parent()
1696 {
1697 paths.push(parent.to_path_buf());
1698 }
1699 }
1700
1701 Ok(paths)
1702}
1703
1704fn contains_glob_meta(pattern: &str) -> bool {
1705 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
1706}
1707
1708fn is_excluded_member(root: &Path, member_root: &Path, excluded: &[String]) -> io::Result<bool> {
1709 let relative = member_root
1710 .strip_prefix(root)
1711 .unwrap_or(member_root)
1712 .to_string_lossy()
1713 .replace('\\', "/");
1714 for pattern in excluded {
1715 let matcher = Pattern::new(pattern).map_err(|err| {
1716 io::Error::new(
1717 io::ErrorKind::InvalidInput,
1718 format!("invalid workspace exclude pattern `{pattern}`: {err}"),
1719 )
1720 })?;
1721 if matcher.matches(&relative) {
1722 return Ok(true);
1723 }
1724 }
1725 Ok(false)
1726}
1727
1728fn add_src_root(root: &Path, scan_roots: &mut BTreeSet<PathBuf>) {
1729 let src = root.join("src");
1730 if src.is_dir() {
1731 scan_roots.insert(src);
1732 }
1733}
1734
1735fn collect_rust_files_in_dir(dir: &Path, files: &mut BTreeSet<PathBuf>) {
1736 for entry in WalkDir::new(dir)
1737 .into_iter()
1738 .filter_map(Result::ok)
1739 .filter(|entry| entry.file_type().is_file())
1740 {
1741 let path = entry.path();
1742 if is_rust_file(path) {
1743 files.insert(path.to_path_buf());
1744 }
1745 }
1746}
1747
1748fn is_rust_file(path: &Path) -> bool {
1749 path.extension().is_some_and(|ext| ext == "rs")
1750}
1751
1752pub(crate) fn is_public(vis: &syn::Visibility) -> bool {
1753 !matches!(vis, syn::Visibility::Inherited)
1754}
1755
1756pub(crate) fn unraw_ident(ident: &syn::Ident) -> String {
1757 let text = ident.to_string();
1758 text.strip_prefix("r#").unwrap_or(&text).to_string()
1759}
1760
1761pub(crate) fn split_segments(name: &str) -> Vec<String> {
1762 if name.contains('_') {
1763 return name
1764 .split('_')
1765 .filter(|segment| !segment.is_empty())
1766 .map(std::string::ToString::to_string)
1767 .collect();
1768 }
1769
1770 let chars: Vec<(usize, char)> = name.char_indices().collect();
1771 if chars.is_empty() {
1772 return Vec::new();
1773 }
1774
1775 let mut starts = vec![0usize];
1776
1777 for i in 1..chars.len() {
1778 let prev = chars[i - 1].1;
1779 let curr = chars[i].1;
1780 let next = chars.get(i + 1).map(|(_, c)| *c);
1781
1782 let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
1783 let acronym_to_word = prev.is_ascii_uppercase()
1784 && curr.is_ascii_uppercase()
1785 && next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
1786
1787 if lower_to_upper || acronym_to_word {
1788 starts.push(chars[i].0);
1789 }
1790 }
1791
1792 let mut out = Vec::with_capacity(starts.len());
1793 for (idx, start) in starts.iter().enumerate() {
1794 let end = if let Some(next) = starts.get(idx + 1) {
1795 *next
1796 } else {
1797 name.len()
1798 };
1799 let seg = &name[*start..end];
1800 if !seg.is_empty() {
1801 out.push(seg.to_string());
1802 }
1803 }
1804
1805 out
1806}
1807
1808pub(crate) fn normalize_segment(segment: &str) -> String {
1809 segment.to_ascii_lowercase()
1810}
1811
1812#[derive(Clone, Copy)]
1813pub(crate) enum NameStyle {
1814 Pascal,
1815 Snake,
1816 ScreamingSnake,
1817}
1818
1819pub(crate) fn detect_name_style(name: &str) -> NameStyle {
1820 if name.contains('_') {
1821 if name
1822 .chars()
1823 .filter(|ch| ch.is_ascii_alphabetic())
1824 .all(|ch| ch.is_ascii_uppercase())
1825 {
1826 NameStyle::ScreamingSnake
1827 } else {
1828 NameStyle::Snake
1829 }
1830 } else {
1831 NameStyle::Pascal
1832 }
1833}
1834
1835pub(crate) fn render_segments(segments: &[String], style: NameStyle) -> String {
1836 match style {
1837 NameStyle::Pascal => segments
1838 .iter()
1839 .map(|segment| {
1840 let lower = segment.to_ascii_lowercase();
1841 let mut chars = lower.chars();
1842 let Some(first) = chars.next() else {
1843 return String::new();
1844 };
1845 let mut rendered = String::new();
1846 rendered.push(first.to_ascii_uppercase());
1847 rendered.extend(chars);
1848 rendered
1849 })
1850 .collect::<Vec<_>>()
1851 .join(""),
1852 NameStyle::Snake => segments
1853 .iter()
1854 .map(|segment| segment.to_ascii_lowercase())
1855 .collect::<Vec<_>>()
1856 .join("_"),
1857 NameStyle::ScreamingSnake => segments
1858 .iter()
1859 .map(|segment| segment.to_ascii_uppercase())
1860 .collect::<Vec<_>>()
1861 .join("_"),
1862 }
1863}
1864
1865pub(crate) fn inferred_file_module_path(path: &Path) -> Vec<String> {
1866 let components = path
1867 .iter()
1868 .map(|component| component.to_string_lossy().to_string())
1869 .collect::<Vec<_>>();
1870 let rel = if let Some(src_idx) = components.iter().rposition(|component| component == "src") {
1871 &components[src_idx + 1..]
1872 } else {
1873 &components[..]
1874 };
1875
1876 if rel.is_empty() || rel.first().is_some_and(|component| component == "bin") {
1877 return Vec::new();
1878 }
1879
1880 let mut module_path = Vec::new();
1881 for (idx, component) in rel.iter().enumerate() {
1882 let is_last = idx + 1 == rel.len();
1883 if is_last {
1884 match component.as_str() {
1885 "lib.rs" | "main.rs" | "mod.rs" => {}
1886 other => {
1887 if let Some(stem) = other.strip_suffix(".rs") {
1888 module_path.push(stem.to_string());
1889 }
1890 }
1891 }
1892 continue;
1893 }
1894
1895 module_path.push(component.to_string());
1896 }
1897
1898 module_path
1899}
1900
1901pub(crate) fn source_root(path: &Path) -> Option<PathBuf> {
1902 let mut root = PathBuf::new();
1903 for component in path.components() {
1904 root.push(component.as_os_str());
1905 if component.as_os_str() == "src" {
1906 return Some(root);
1907 }
1908 }
1909 None
1910}
1911
1912pub(crate) fn parent_module_files(src_root: &Path, prefix: &[String]) -> Vec<PathBuf> {
1913 if prefix.is_empty() {
1914 return vec![src_root.join("lib.rs"), src_root.join("main.rs")];
1915 }
1916
1917 let joined = prefix.join("/");
1918 vec![
1919 src_root.join(format!("{joined}.rs")),
1920 src_root.join(joined).join("mod.rs"),
1921 ]
1922}
1923
1924pub(crate) fn replace_path_fix(replacement: impl Into<String>) -> DiagnosticFix {
1925 DiagnosticFix {
1926 kind: DiagnosticFixKind::ReplacePath,
1927 replacement: replacement.into(),
1928 }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933 use super::{
1934 CheckMode, Diagnostic, DiagnosticSelection, LintProfile, NamespaceSettings,
1935 WorkspaceReport, check_exit_code, parse_check_mode, parse_lint_profile, split_segments,
1936 };
1937
1938 #[test]
1939 fn splits_pascal_camel_snake_and_acronyms() {
1940 assert_eq!(split_segments("WhatEver"), vec!["What", "Ever"]);
1941 assert_eq!(split_segments("whatEver"), vec!["what", "Ever"]);
1942 assert_eq!(split_segments("what_ever"), vec!["what", "ever"]);
1943 assert_eq!(split_segments("HTTPServer"), vec!["HTTP", "Server"]);
1944 }
1945
1946 #[test]
1947 fn parses_check_modes() {
1948 assert_eq!(parse_check_mode("off"), Ok(CheckMode::Off));
1949 assert_eq!(parse_check_mode("warn"), Ok(CheckMode::Warn));
1950 assert_eq!(parse_check_mode("deny"), Ok(CheckMode::Deny));
1951 }
1952
1953 #[test]
1954 fn check_mode_supports_standard_parsing() {
1955 assert_eq!("off".parse::<CheckMode>(), Ok(CheckMode::Off));
1956 assert_eq!("warn".parse::<CheckMode>(), Ok(CheckMode::Warn));
1957 assert_eq!("deny".parse::<CheckMode>(), Ok(CheckMode::Deny));
1958 }
1959
1960 #[test]
1961 fn rejects_invalid_check_mode() {
1962 let err = parse_check_mode("strict").unwrap_err();
1963 assert!(err.contains("expected off|warn|deny"));
1964 }
1965
1966 #[test]
1967 fn lint_profile_supports_standard_parsing() {
1968 assert_eq!(parse_lint_profile("core"), Ok(LintProfile::Core));
1969 assert_eq!(parse_lint_profile("surface"), Ok(LintProfile::Surface));
1970 assert_eq!(parse_lint_profile("strict"), Ok(LintProfile::Strict));
1971 }
1972
1973 #[test]
1974 fn rejects_invalid_lint_profile() {
1975 let err = parse_lint_profile("default").unwrap_err();
1976 assert!(err.contains("expected core|surface|strict"));
1977 }
1978
1979 #[test]
1980 fn diagnostic_selection_supports_standard_parsing() {
1981 assert_eq!(
1982 "all".parse::<DiagnosticSelection>(),
1983 Ok(DiagnosticSelection::All)
1984 );
1985 assert_eq!(
1986 "policy".parse::<DiagnosticSelection>(),
1987 Ok(DiagnosticSelection::Policy)
1988 );
1989 assert_eq!(
1990 "advisory".parse::<DiagnosticSelection>(),
1991 Ok(DiagnosticSelection::Advisory)
1992 );
1993 }
1994
1995 #[test]
1996 fn rejects_invalid_diagnostic_selection() {
1997 let err = "warnings".parse::<DiagnosticSelection>().unwrap_err();
1998 assert!(err.contains("expected all|policy|advisory"));
1999 }
2000
2001 #[test]
2002 fn check_exit_code_follows_warn_and_deny_semantics() {
2003 let clean = WorkspaceReport {
2004 scanned_files: 1,
2005 files_with_violations: 0,
2006 diagnostics: Vec::new(),
2007 };
2008 assert_eq!(check_exit_code(&clean, CheckMode::Warn), 0);
2009 assert_eq!(check_exit_code(&clean, CheckMode::Deny), 0);
2010
2011 let with_policy = WorkspaceReport {
2012 scanned_files: 1,
2013 files_with_violations: 1,
2014 diagnostics: vec![Diagnostic::policy(None, None, "lint", "warning")],
2015 };
2016 assert_eq!(check_exit_code(&with_policy, CheckMode::Warn), 0);
2017 assert_eq!(check_exit_code(&with_policy, CheckMode::Deny), 2);
2018
2019 let with_error = WorkspaceReport {
2020 scanned_files: 1,
2021 files_with_violations: 1,
2022 diagnostics: vec![Diagnostic::error(None, None, "error")],
2023 };
2024 assert_eq!(check_exit_code(&with_error, CheckMode::Warn), 1);
2025 assert_eq!(check_exit_code(&with_error, CheckMode::Deny), 1);
2026 }
2027
2028 #[test]
2029 fn namespace_settings_defaults_cover_generic_nouns_and_weak_modules() {
2030 let settings = NamespaceSettings::default();
2031 assert!(settings.generic_nouns.contains("Repository"));
2032 assert!(settings.generic_nouns.contains("Id"));
2033 assert!(settings.generic_nouns.contains("Outcome"));
2034 assert!(settings.weak_modules.contains("storage"));
2035 assert!(settings.catch_all_modules.contains("helpers"));
2036 assert!(settings.organizational_modules.contains("error"));
2037 assert!(settings.organizational_modules.contains("request"));
2038 assert!(settings.organizational_modules.contains("response"));
2039 assert!(settings.namespace_preserving_modules.contains("email"));
2040 assert!(settings.namespace_preserving_modules.contains("components"));
2041 assert!(settings.namespace_preserving_modules.contains("partials"));
2042 assert!(settings.namespace_preserving_modules.contains("trace"));
2043 assert!(settings.namespace_preserving_modules.contains("write_back"));
2044 assert!(!settings.namespace_preserving_modules.contains("views"));
2045 assert!(!settings.namespace_preserving_modules.contains("handlers"));
2046 }
2047
2048 #[test]
2049 fn workspace_report_can_filter_policy_and_advisory_diagnostics() {
2050 let report = WorkspaceReport {
2051 scanned_files: 2,
2052 files_with_violations: 2,
2053 diagnostics: vec![
2054 Diagnostic::policy(Some("src/policy.rs".into()), Some(1), "policy", "policy"),
2055 Diagnostic::advisory(
2056 Some("src/advisory.rs".into()),
2057 Some(2),
2058 "advisory",
2059 "advisory",
2060 ),
2061 Diagnostic::error(Some("src/error.rs".into()), Some(3), "error"),
2062 ],
2063 };
2064
2065 let policy_only = report.filtered(DiagnosticSelection::Policy);
2066 assert_eq!(policy_only.files_with_violations, 2);
2067 assert_eq!(policy_only.error_count(), 1);
2068 assert_eq!(policy_only.policy_warning_count(), 1);
2069 assert_eq!(policy_only.advisory_warning_count(), 0);
2070
2071 let advisory_only = report.filtered(DiagnosticSelection::Advisory);
2072 assert_eq!(advisory_only.files_with_violations, 2);
2073 assert_eq!(advisory_only.error_count(), 1);
2074 assert_eq!(advisory_only.policy_warning_count(), 0);
2075 assert_eq!(advisory_only.advisory_warning_count(), 1);
2076 }
2077
2078 #[test]
2079 fn workspace_report_can_filter_diagnostics_by_profile() {
2080 let report = WorkspaceReport {
2081 scanned_files: 3,
2082 files_with_violations: 3,
2083 diagnostics: vec![
2084 Diagnostic::policy(
2085 Some("src/core.rs".into()),
2086 Some(1),
2087 "namespace_flat_use",
2088 "core",
2089 ),
2090 Diagnostic::policy(
2091 Some("src/surface.rs".into()),
2092 Some(2),
2093 "api_missing_parent_surface_export",
2094 "surface",
2095 ),
2096 Diagnostic::advisory(
2097 Some("src/strict.rs".into()),
2098 Some(3),
2099 "api_candidate_semantic_module",
2100 "strict",
2101 ),
2102 Diagnostic::error(Some("Cargo.toml".into()), None, "config"),
2103 ],
2104 };
2105
2106 let core = report.filtered_by_profile(LintProfile::Core);
2107 assert_eq!(core.files_with_violations, 2);
2108 assert_eq!(core.diagnostics.len(), 2);
2109 assert!(
2110 core.diagnostics
2111 .iter()
2112 .any(|diag| diag.code() == Some("namespace_flat_use"))
2113 );
2114 assert!(core.diagnostics.iter().any(|diag| diag.code().is_none()));
2115
2116 let surface = report.filtered_by_profile(LintProfile::Surface);
2117 assert_eq!(surface.files_with_violations, 3);
2118 assert_eq!(surface.diagnostics.len(), 3);
2119 assert!(
2120 surface
2121 .diagnostics
2122 .iter()
2123 .any(|diag| diag.code() == Some("api_missing_parent_surface_export"))
2124 );
2125 assert!(
2126 !surface
2127 .diagnostics
2128 .iter()
2129 .any(|diag| diag.code() == Some("api_candidate_semantic_module"))
2130 );
2131 }
2132}