Skip to main content

modum_core/
lib.rs

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}