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