Skip to main content

modum_core/
diagnostic.rs

1use std::path::PathBuf;
2
3use serde::{Serialize, Serializer, ser::SerializeStruct};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Default)]
6#[serde(rename_all = "snake_case")]
7pub enum LintProfile {
8    Core,
9    Surface,
10    #[default]
11    Strict,
12}
13
14impl LintProfile {
15    pub fn as_str(self) -> &'static str {
16        match self {
17            Self::Core => "core",
18            Self::Surface => "surface",
19            Self::Strict => "strict",
20        }
21    }
22}
23
24impl std::str::FromStr for LintProfile {
25    type Err = String;
26
27    fn from_str(raw: &str) -> Result<Self, Self::Err> {
28        match raw {
29            "core" => Ok(Self::Core),
30            "surface" => Ok(Self::Surface),
31            "strict" => Ok(Self::Strict),
32            _ => Err(format!(
33                "invalid profile `{raw}`; expected core|surface|strict"
34            )),
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct DiagnosticCodeInfo {
41    pub profile: LintProfile,
42    pub summary: &'static str,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
46pub enum DiagnosticLevel {
47    Warning,
48    Error,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
52pub enum DiagnosticClass {
53    ToolError,
54    ToolWarning,
55    PolicyError { code: String },
56    PolicyWarning { code: String },
57    AdvisoryWarning { code: String },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum DiagnosticFixKind {
63    ReplacePath,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
67pub struct DiagnosticFix {
68    pub kind: DiagnosticFixKind,
69    pub replacement: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
73pub struct Diagnostic {
74    pub class: DiagnosticClass,
75    pub file: Option<PathBuf>,
76    pub line: Option<usize>,
77    pub fix: Option<DiagnosticFix>,
78    pub message: String,
79}
80
81impl Diagnostic {
82    pub fn error(file: Option<PathBuf>, line: Option<usize>, message: impl Into<String>) -> Self {
83        Self {
84            class: DiagnosticClass::ToolError,
85            file,
86            line,
87            fix: None,
88            message: message.into(),
89        }
90    }
91
92    pub fn warning(file: Option<PathBuf>, line: Option<usize>, message: impl Into<String>) -> Self {
93        Self {
94            class: DiagnosticClass::ToolWarning,
95            file,
96            line,
97            fix: None,
98            message: message.into(),
99        }
100    }
101
102    pub fn policy(
103        file: Option<PathBuf>,
104        line: Option<usize>,
105        code: impl Into<String>,
106        message: impl Into<String>,
107    ) -> Self {
108        Self {
109            class: DiagnosticClass::PolicyWarning { code: code.into() },
110            file,
111            line,
112            fix: None,
113            message: message.into(),
114        }
115    }
116
117    pub fn policy_error(
118        file: Option<PathBuf>,
119        line: Option<usize>,
120        code: impl Into<String>,
121        message: impl Into<String>,
122    ) -> Self {
123        Self {
124            class: DiagnosticClass::PolicyError { code: code.into() },
125            file,
126            line,
127            fix: None,
128            message: message.into(),
129        }
130    }
131
132    pub fn advisory(
133        file: Option<PathBuf>,
134        line: Option<usize>,
135        code: impl Into<String>,
136        message: impl Into<String>,
137    ) -> Self {
138        Self {
139            class: DiagnosticClass::AdvisoryWarning { code: code.into() },
140            file,
141            line,
142            fix: None,
143            message: message.into(),
144        }
145    }
146
147    pub fn with_fix(mut self, fix: DiagnosticFix) -> Self {
148        self.fix = Some(fix);
149        self
150    }
151
152    pub fn level(&self) -> DiagnosticLevel {
153        match self.class {
154            DiagnosticClass::ToolError | DiagnosticClass::PolicyError { .. } => {
155                DiagnosticLevel::Error
156            }
157            DiagnosticClass::ToolWarning
158            | DiagnosticClass::PolicyWarning { .. }
159            | DiagnosticClass::AdvisoryWarning { .. } => DiagnosticLevel::Warning,
160        }
161    }
162
163    pub fn code(&self) -> Option<&str> {
164        match &self.class {
165            DiagnosticClass::PolicyError { code }
166            | DiagnosticClass::PolicyWarning { code }
167            | DiagnosticClass::AdvisoryWarning { code } => Some(code),
168            DiagnosticClass::ToolError | DiagnosticClass::ToolWarning => None,
169        }
170    }
171
172    pub fn profile(&self) -> Option<LintProfile> {
173        self.code()
174            .and_then(|code| diagnostic_code_info(code).map(|info| info.profile))
175    }
176
177    pub fn is_error(&self) -> bool {
178        matches!(
179            self.class,
180            DiagnosticClass::ToolError | DiagnosticClass::PolicyError { .. }
181        )
182    }
183
184    pub fn is_policy_warning(&self) -> bool {
185        matches!(self.class, DiagnosticClass::PolicyWarning { .. })
186    }
187
188    pub fn is_advisory_warning(&self) -> bool {
189        matches!(
190            self.class,
191            DiagnosticClass::ToolWarning | DiagnosticClass::AdvisoryWarning { .. }
192        )
193    }
194
195    pub fn is_policy_violation(&self) -> bool {
196        matches!(
197            self.class,
198            DiagnosticClass::PolicyError { .. } | DiagnosticClass::PolicyWarning { .. }
199        )
200    }
201
202    pub fn included_in_profile(&self, profile: LintProfile) -> bool {
203        match &self.class {
204            DiagnosticClass::ToolError | DiagnosticClass::ToolWarning => true,
205            DiagnosticClass::PolicyError { code }
206            | DiagnosticClass::PolicyWarning { code }
207            | DiagnosticClass::AdvisoryWarning { code } => {
208                profile >= minimum_profile_for_code(code)
209            }
210        }
211    }
212}
213
214impl Serialize for Diagnostic {
215    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
216    where
217        S: Serializer,
218    {
219        let mut state = serializer.serialize_struct("Diagnostic", 8)?;
220        state.serialize_field("level", &self.level())?;
221        state.serialize_field("file", &self.file)?;
222        state.serialize_field("line", &self.line)?;
223        state.serialize_field("code", &self.code())?;
224        state.serialize_field("profile", &self.profile())?;
225        state.serialize_field("policy", &self.is_policy_violation())?;
226        state.serialize_field("fix", &self.fix)?;
227        state.serialize_field("message", &self.message)?;
228        state.end()
229    }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum DiagnosticSelection {
234    All,
235    Policy,
236    Advisory,
237}
238
239impl DiagnosticSelection {
240    pub fn includes(self, diagnostic: &Diagnostic) -> bool {
241        match self {
242            Self::All => true,
243            Self::Policy => diagnostic.is_error() || diagnostic.is_policy_violation(),
244            Self::Advisory => diagnostic.is_error() || diagnostic.is_advisory_warning(),
245        }
246    }
247
248    pub fn report_label(self) -> Option<&'static str> {
249        match self {
250            Self::All => None,
251            Self::Policy => Some("policy diagnostics and errors only"),
252            Self::Advisory => Some("advisory diagnostics and errors only"),
253        }
254    }
255}
256
257pub fn diagnostic_code_info(code: &str) -> Option<DiagnosticCodeInfo> {
258    let (profile, summary) = match code {
259        "namespace_flat_use" => (
260            LintProfile::Core,
261            "Flattened imports hide useful namespace context for generic leaves.",
262        ),
263        "namespace_flat_use_preserve_module" => (
264            LintProfile::Core,
265            "Flattened imports hide a module that should stay visible at call sites.",
266        ),
267        "namespace_flat_use_redundant_leaf_context" => (
268            LintProfile::Core,
269            "Flattened imports keep parent context in the leaf instead of the path.",
270        ),
271        "namespace_redundant_qualified_generic" => (
272            LintProfile::Core,
273            "Qualified paths repeat a generic category that the leaf already names.",
274        ),
275        "namespace_aliased_qualified_path" => (
276            LintProfile::Core,
277            "A namespace alias flattens a semantic path instead of keeping the real module visible.",
278        ),
279        "namespace_parent_surface" => (
280            LintProfile::Core,
281            "Imports bypass a canonical parent surface that already re-exports the binding.",
282        ),
283        "namespace_flat_type_alias" => (
284            LintProfile::Core,
285            "A type alias hides useful namespace context for a generic aliased leaf.",
286        ),
287        "namespace_flat_type_alias_preserve_module" => (
288            LintProfile::Core,
289            "A type alias hides a module that should stay visible in the aliased type path.",
290        ),
291        "namespace_flat_type_alias_redundant_leaf_context" => (
292            LintProfile::Core,
293            "A type alias keeps redundant parent context in the alias name instead of the path.",
294        ),
295        "namespace_prelude_glob_import" => (
296            LintProfile::Core,
297            "A prelude glob import hides the real source modules instead of keeping useful namespace context visible.",
298        ),
299        "namespace_glob_preserve_module" => (
300            LintProfile::Core,
301            "A glob import flattens a configured namespace-preserving module instead of keeping that module visible.",
302        ),
303        "internal_catch_all_module" => (
304            LintProfile::Core,
305            "An internal module name is a catch-all bucket instead of a stable domain or facet.",
306        ),
307        "internal_repeated_module_segment" => (
308            LintProfile::Core,
309            "An internal nested module repeats the same segment instead of adding meaning.",
310        ),
311        "internal_organizational_submodule_flatten" => (
312            LintProfile::Core,
313            "An internal organizational module leaks category structure that should usually be flattened.",
314        ),
315        "internal_weak_module_generic_leaf" => (
316            LintProfile::Core,
317            "An internal item leaf is too generic for a weak or technical parent module.",
318        ),
319        "internal_redundant_leaf_context" => (
320            LintProfile::Core,
321            "An internal item leaf repeats context the parent module already provides.",
322        ),
323        "internal_adapter_redundant_leaf_context" => (
324            LintProfile::Core,
325            "An internal adapter leaf repeats implementation context the parent module already provides.",
326        ),
327        "internal_redundant_category_suffix" => (
328            LintProfile::Core,
329            "An internal item leaf repeats the parent category in a redundant suffix.",
330        ),
331        "api_catch_all_module" => (
332            LintProfile::Core,
333            "A surface-visible module is a catch-all bucket instead of a stable domain or facet.",
334        ),
335        "api_repeated_module_segment" => (
336            LintProfile::Core,
337            "A surface-visible nested module repeats the same segment instead of adding meaning.",
338        ),
339        "namespace_flat_pub_use" => (
340            LintProfile::Surface,
341            "A re-export flattens useful namespace context out of the caller-facing path.",
342        ),
343        "namespace_flat_pub_use_preserve_module" => (
344            LintProfile::Surface,
345            "A re-export hides a module that should stay visible in the caller-facing path.",
346        ),
347        "namespace_flat_pub_use_redundant_leaf_context" => (
348            LintProfile::Surface,
349            "A re-export keeps parent context in the leaf instead of the path.",
350        ),
351        "api_missing_parent_surface_export" => (
352            LintProfile::Surface,
353            "A child module surface should usually also expose a readable parent binding.",
354        ),
355        "api_anyhow_error_surface" => (
356            LintProfile::Surface,
357            "A caller-facing surface leaks `anyhow` instead of exposing a crate-owned typed error boundary.",
358        ),
359        "api_semantic_string_scalar" => (
360            LintProfile::Surface,
361            "A caller-facing semantic scalar is kept as a raw string instead of a typed boundary value.",
362        ),
363        "api_semantic_numeric_scalar" => (
364            LintProfile::Surface,
365            "A caller-facing semantic scalar is kept as a raw integer instead of a typed boundary value.",
366        ),
367        "api_weak_module_generic_leaf" => (
368            LintProfile::Surface,
369            "A surface-visible item leaf is too generic for a weak or technical parent module.",
370        ),
371        "api_redundant_leaf_context" => (
372            LintProfile::Surface,
373            "A surface-visible item leaf repeats context the parent module already provides.",
374        ),
375        "api_redundant_category_suffix" => (
376            LintProfile::Surface,
377            "A surface-visible item leaf repeats the parent category in a redundant suffix.",
378        ),
379        "api_organizational_submodule_flatten" => (
380            LintProfile::Surface,
381            "A surface-visible organizational module should usually be flattened out of the path.",
382        ),
383        "api_candidate_semantic_module" => (
384            LintProfile::Strict,
385            "A family of sibling items suggests a stronger semantic module surface.",
386        ),
387        "api_candidate_semantic_module_unsupported_construct" => (
388            LintProfile::Strict,
389            "Semantic-module family inference was skipped because the parsed source contains unsupported constructs.",
390        ),
391        "api_manual_enum_string_helper" => (
392            LintProfile::Strict,
393            "A public enum exposes manual string helpers that should usually be standard traits or derives.",
394        ),
395        "api_ad_hoc_parse_helper" => (
396            LintProfile::Strict,
397            "A public enum parsing helper should usually be modeled as `FromStr` or `TryFrom<&str>`.",
398        ),
399        "api_parallel_enum_metadata_helper" => (
400            LintProfile::Strict,
401            "Parallel enum metadata helpers suggest a typed descriptor surface instead of repeated matches.",
402        ),
403        "api_strum_serialize_all_candidate" => (
404            LintProfile::Strict,
405            "Per-variant `strum` strings could be replaced by one enum-level `serialize_all` rule.",
406        ),
407        "api_builder_candidate" => (
408            LintProfile::Strict,
409            "A configuration-heavy entrypoint would read better as a builder or typed options surface.",
410        ),
411        "api_repeated_parameter_cluster" => (
412            LintProfile::Strict,
413            "Several entrypoints repeat the same positional parameter cluster instead of sharing a typed shape.",
414        ),
415        "api_optional_parameter_builder" => (
416            LintProfile::Strict,
417            "Optional positional parameters suggest a builder so callers can omit unset values.",
418        ),
419        "api_defaulted_optional_parameter" => (
420            LintProfile::Strict,
421            "Defaulted optional positional parameters suggest a builder rather than `None`-passing.",
422        ),
423        "callsite_maybe_some" => (
424            LintProfile::Strict,
425            "A `maybe_*` call wraps a direct value in `Some(...)` instead of using the direct setter or forwarding an existing option.",
426        ),
427        "api_standalone_builder_surface" => (
428            LintProfile::Strict,
429            "Parallel `with_*` or `set_*` free functions suggest a real builder surface.",
430        ),
431        "api_boolean_protocol_decision" => (
432            LintProfile::Strict,
433            "A boolean encodes a domain or protocol decision that should usually be typed.",
434        ),
435        "api_boolean_flag_cluster" => (
436            LintProfile::Strict,
437            "Several booleans jointly shape behavior and suggest a typed mode or options surface.",
438        ),
439        "api_forwarding_compat_wrapper" => (
440            LintProfile::Strict,
441            "A helper only forwards to an existing standard conversion trait.",
442        ),
443        "api_string_error_surface" => (
444            LintProfile::Strict,
445            "A caller-facing error surface is carried as raw strings instead of a typed error boundary.",
446        ),
447        "api_manual_error_surface" => (
448            LintProfile::Strict,
449            "A public error manually exposes formatting and error boilerplate instead of a smaller typed boundary.",
450        ),
451        "api_raw_key_value_bag" => (
452            LintProfile::Strict,
453            "A caller-facing metadata or bag surface is modeled as raw string key-value pairs instead of a typed shape.",
454        ),
455        "api_stringly_protocol_collection" => (
456            LintProfile::Strict,
457            "Protocol or state collections are modeled as raw strings instead of typed values.",
458        ),
459        "api_stringly_protocol_parameter" => (
460            LintProfile::Strict,
461            "A boundary takes protocol or state descriptors as raw strings instead of typed values.",
462        ),
463        "api_stringly_model_scaffold" => (
464            LintProfile::Strict,
465            "A model carries semantic descriptor fields as raw strings instead of typed structure.",
466        ),
467        "api_integer_protocol_parameter" => (
468            LintProfile::Strict,
469            "A caller-facing protocol concept is modeled as a raw integer instead of a typed enum or newtype.",
470        ),
471        "api_raw_id_surface" => (
472            LintProfile::Strict,
473            "A caller-facing id is modeled as a raw string or primitive integer instead of a typed id value.",
474        ),
475        "api_manual_flag_set" => (
476            LintProfile::Strict,
477            "Parallel integer flag constants suggest a typed flags boundary instead of manual bit masks.",
478        ),
479        _ => return None,
480    };
481
482    Some(DiagnosticCodeInfo { profile, summary })
483}
484
485fn minimum_profile_for_code(code: &str) -> LintProfile {
486    diagnostic_code_info(code)
487        .map(|info| info.profile)
488        .unwrap_or(LintProfile::Strict)
489}
490
491impl std::str::FromStr for DiagnosticSelection {
492    type Err = String;
493
494    fn from_str(raw: &str) -> Result<Self, Self::Err> {
495        match raw {
496            "all" => Ok(Self::All),
497            "policy" => Ok(Self::Policy),
498            "advisory" => Ok(Self::Advisory),
499            _ => Err(format!(
500                "invalid show mode `{raw}`; expected all|policy|advisory"
501            )),
502        }
503    }
504}