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