Skip to main content

cssforge_core/
model.rs

1use serde::{Deserialize, Serialize};
2use std::{fmt, path::PathBuf};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
5#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
6pub enum Safety {
7    Safe,
8    Review,
9    Unsafe,
10    Unsupported,
11    NoOp,
12}
13
14impl Safety {
15    pub const fn label(self) -> &'static str {
16        match self {
17            Self::Safe => "SAFE",
18            Self::Review => "REVIEW",
19            Self::Unsafe => "UNSAFE",
20            Self::Unsupported => "UNSUPPORTED",
21            Self::NoOp => "NO_OP",
22        }
23    }
24}
25
26impl fmt::Display for Safety {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(self.label())
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum SafetyLevel {
35    AnalysisOnly,
36    FormattingOnly,
37    ProvenLocalRefactor,
38    SemanticReview,
39    Architectural,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum RuleId {
45    NestPseudoClass,
46    NestPseudoElement,
47    NestAttribute,
48    NestCompound,
49    NestDescendant,
50    NestCombinator,
51    NestMedia,
52    NestSupports,
53    NestContainer,
54    NestStartingStyle,
55    FactorSelectorList,
56    ConsolidateNot,
57    ModernizeIs,
58    ModernizeWhere,
59    ModernizeMediaRange,
60    MergeSameNamedLayer,
61    MergeAdjacentMedia,
62    MergeAdjacentSupports,
63    MergeAdjacentContainer,
64    MergeIdenticalScope,
65    MergeIdenticalStartingStyle,
66    MergeAdjacentIdenticalSelector,
67    MergeIdenticalRuleBodies,
68    FactorIdenticalStatesWithIs,
69    GatherRelatedSelectorRules,
70    DedupeIdenticalDeclarations,
71    NestLayerBySelector,
72    PruneOverriddenDeclarations,
73}
74
75impl RuleId {
76    pub const ALL: [RuleId; 28] = [
77        RuleId::NestPseudoClass,
78        RuleId::NestPseudoElement,
79        RuleId::NestAttribute,
80        RuleId::NestCompound,
81        RuleId::NestDescendant,
82        RuleId::NestCombinator,
83        RuleId::NestMedia,
84        RuleId::NestSupports,
85        RuleId::NestContainer,
86        RuleId::NestStartingStyle,
87        RuleId::FactorSelectorList,
88        RuleId::ConsolidateNot,
89        RuleId::ModernizeIs,
90        RuleId::ModernizeWhere,
91        RuleId::ModernizeMediaRange,
92        RuleId::MergeSameNamedLayer,
93        RuleId::MergeAdjacentMedia,
94        RuleId::MergeAdjacentSupports,
95        RuleId::MergeAdjacentContainer,
96        RuleId::MergeIdenticalScope,
97        RuleId::MergeIdenticalStartingStyle,
98        RuleId::MergeAdjacentIdenticalSelector,
99        RuleId::MergeIdenticalRuleBodies,
100        RuleId::FactorIdenticalStatesWithIs,
101        RuleId::GatherRelatedSelectorRules,
102        RuleId::DedupeIdenticalDeclarations,
103        RuleId::NestLayerBySelector,
104        RuleId::PruneOverriddenDeclarations,
105    ];
106
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::NestPseudoClass => "nest-pseudo-class",
110            Self::NestPseudoElement => "nest-pseudo-element",
111            Self::NestAttribute => "nest-attribute",
112            Self::NestCompound => "nest-compound",
113            Self::NestDescendant => "nest-descendant",
114            Self::NestCombinator => "nest-combinator",
115            Self::NestMedia => "nest-media",
116            Self::NestSupports => "nest-supports",
117            Self::NestContainer => "nest-container",
118            Self::NestStartingStyle => "nest-starting-style",
119            Self::FactorSelectorList => "factor-selector-list",
120            Self::ConsolidateNot => "consolidate-not",
121            Self::ModernizeIs => "modernize-is",
122            Self::ModernizeWhere => "modernize-where",
123            Self::ModernizeMediaRange => "modernize-media-range-syntax",
124            Self::MergeSameNamedLayer => "merge-same-named-layer",
125            Self::MergeAdjacentMedia => "merge-adjacent-media",
126            Self::MergeAdjacentSupports => "merge-adjacent-supports",
127            Self::MergeAdjacentContainer => "merge-adjacent-container",
128            Self::MergeIdenticalScope => "merge-identical-scope",
129            Self::MergeIdenticalStartingStyle => "merge-identical-starting-style",
130            Self::MergeAdjacentIdenticalSelector => "merge-adjacent-identical-selector",
131            Self::MergeIdenticalRuleBodies => "merge-identical-rule-bodies",
132            Self::FactorIdenticalStatesWithIs => "factor-identical-states-with-is",
133            Self::GatherRelatedSelectorRules => "gather-related-selector-rules",
134            Self::DedupeIdenticalDeclarations => "dedupe-identical-declarations",
135            Self::NestLayerBySelector => "nest-layer-by-selector",
136            Self::PruneOverriddenDeclarations => "prune-overridden-declarations",
137        }
138    }
139}
140
141impl fmt::Display for RuleId {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(self.as_str())
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum RuleSection {
150    Modernize,
151    Refactor,
152}
153
154impl RuleSection {
155    pub const ALL: [RuleSection; 2] = [RuleSection::Modernize, RuleSection::Refactor];
156
157    pub const fn label(self) -> &'static str {
158        match self {
159            Self::Modernize => "MODERNIZE (Native Nesting, Range Syntax & Selectors)",
160            Self::Refactor => "REFACTOR (Consolidation, Deduplication & Structural Cleanup)",
161        }
162    }
163}
164
165#[derive(Debug, Clone)]
166pub struct RuleDefinition {
167    pub id: RuleId,
168    pub section: RuleSection,
169    pub title: &'static str,
170    pub category: &'static str,
171    pub safety_level: SafetyLevel,
172    pub description: &'static str,
173}
174
175pub fn rule_definitions() -> Vec<RuleDefinition> {
176    vec![
177        RuleDefinition {
178            id: RuleId::NestPseudoClass,
179            section: RuleSection::Modernize,
180            title: "Nest pseudo-classes",
181            category: "Nesting",
182            safety_level: SafetyLevel::ProvenLocalRefactor,
183            description: "Nest adjacent same-parent pseudo-class rules such as .button:hover -> &:hover.",
184        },
185        RuleDefinition {
186            id: RuleId::NestPseudoElement,
187            section: RuleSection::Modernize,
188            title: "Nest pseudo-elements",
189            category: "Nesting",
190            safety_level: SafetyLevel::ProvenLocalRefactor,
191            description: "Nest adjacent same-parent pseudo-elements such as .card::before -> &::before.",
192        },
193        RuleDefinition {
194            id: RuleId::NestAttribute,
195            section: RuleSection::Modernize,
196            title: "Nest attribute states",
197            category: "Nesting",
198            safety_level: SafetyLevel::ProvenLocalRefactor,
199            description: "Nest adjacent attribute states such as .button[disabled] -> &[disabled].",
200        },
201        RuleDefinition {
202            id: RuleId::NestCompound,
203            section: RuleSection::Modernize,
204            title: "Nest compound states",
205            category: "Nesting",
206            safety_level: SafetyLevel::ProvenLocalRefactor,
207            description: "Nest adjacent compound states such as .item.active -> &.active.",
208        },
209        RuleDefinition {
210            id: RuleId::NestDescendant,
211            section: RuleSection::Modernize,
212            title: "Nest descendants",
213            category: "Nesting",
214            safety_level: SafetyLevel::ProvenLocalRefactor,
215            description: "Nest adjacent descendant selectors (.card .title -> .title) with exact relationship proof.",
216        },
217        RuleDefinition {
218            id: RuleId::NestCombinator,
219            section: RuleSection::Modernize,
220            title: "Nest combinators",
221            category: "Nesting",
222            safety_level: SafetyLevel::ProvenLocalRefactor,
223            description: "Nest adjacent child and sibling combinators (> + ~) under their exact parent selector.",
224        },
225        RuleDefinition {
226            id: RuleId::NestMedia,
227            section: RuleSection::Modernize,
228            title: "Nest local @media",
229            category: "At-rules",
230            safety_level: SafetyLevel::ProvenLocalRefactor,
231            description: "Nest an immediately-following @media block containing matching selector rules.",
232        },
233        RuleDefinition {
234            id: RuleId::NestSupports,
235            section: RuleSection::Modernize,
236            title: "Nest local @supports",
237            category: "At-rules",
238            safety_level: SafetyLevel::ProvenLocalRefactor,
239            description: "Nest an immediately-following @supports block containing matching selector rules.",
240        },
241        RuleDefinition {
242            id: RuleId::NestContainer,
243            section: RuleSection::Modernize,
244            title: "Nest local @container",
245            category: "At-rules",
246            safety_level: SafetyLevel::SemanticReview,
247            description: "Nest an immediately-following @container block for matching selector rules.",
248        },
249        RuleDefinition {
250            id: RuleId::NestStartingStyle,
251            section: RuleSection::Modernize,
252            title: "Nest @starting-style",
253            category: "At-rules",
254            safety_level: SafetyLevel::ProvenLocalRefactor,
255            description: "Nest an immediately-following @starting-style block for the matching parent selector.",
256        },
257        RuleDefinition {
258            id: RuleId::FactorSelectorList,
259            section: RuleSection::Modernize,
260            title: "Factor selector lists",
261            category: "Nesting",
262            safety_level: SafetyLevel::ProvenLocalRefactor,
263            description: "Factor comma-separated selectors sharing a common base (.marker, .marker::before -> .marker { &, &::before }).",
264        },
265        RuleDefinition {
266            id: RuleId::ConsolidateNot,
267            section: RuleSection::Modernize,
268            title: "Consolidate :not() selectors",
269            category: "Selectors",
270            safety_level: SafetyLevel::SemanticReview,
271            description: "Consolidate chained :not() selectors like :not(a):not(b) into :not(a, b) (review required for additive specificity change).",
272        },
273        RuleDefinition {
274            id: RuleId::ModernizeIs,
275            section: RuleSection::Modernize,
276            title: "Factor with :is()",
277            category: "Selectors",
278            safety_level: SafetyLevel::ProvenLocalRefactor,
279            description: "Factor selector-list alternatives with uniform specificity into :is(...) grouping.",
280        },
281        RuleDefinition {
282            id: RuleId::ModernizeWhere,
283            section: RuleSection::Modernize,
284            title: "Modernize with :where()",
285            category: "Selectors",
286            safety_level: SafetyLevel::Architectural,
287            description: "Factor selector-list alternatives into :where(...) for zero-specificity defaults (review required).",
288        },
289        RuleDefinition {
290            id: RuleId::ModernizeMediaRange,
291            section: RuleSection::Modernize,
292            title: "Modernize media range syntax",
293            category: "At-rules",
294            safety_level: SafetyLevel::FormattingOnly,
295            description: "Convert min/max-width and min/max-height media features to CSS Range Syntax (e.g. (width >= 800px)).",
296        },
297        RuleDefinition {
298            id: RuleId::MergeSameNamedLayer,
299            section: RuleSection::Refactor,
300            title: "Merge same named @layer blocks",
301            category: "Structural Refactoring",
302            safety_level: SafetyLevel::ProvenLocalRefactor,
303            description: "Consolidate separated blocks belonging to the same named @layer into their canonical first occurrence.",
304        },
305        RuleDefinition {
306            id: RuleId::MergeAdjacentMedia,
307            section: RuleSection::Refactor,
308            title: "Merge adjacent @media queries",
309            category: "Structural Refactoring",
310            safety_level: SafetyLevel::ProvenLocalRefactor,
311            description: "Combine consecutive @media blocks having identical query conditions into a single block.",
312        },
313        RuleDefinition {
314            id: RuleId::MergeAdjacentSupports,
315            section: RuleSection::Refactor,
316            title: "Merge adjacent @supports queries",
317            category: "Structural Refactoring",
318            safety_level: SafetyLevel::ProvenLocalRefactor,
319            description: "Combine consecutive @supports blocks having identical feature conditions into a single block.",
320        },
321        RuleDefinition {
322            id: RuleId::MergeAdjacentContainer,
323            section: RuleSection::Refactor,
324            title: "Merge adjacent @container queries",
325            category: "Structural Refactoring",
326            safety_level: SafetyLevel::ProvenLocalRefactor,
327            description: "Combine consecutive @container blocks having identical container name and query conditions.",
328        },
329        RuleDefinition {
330            id: RuleId::MergeIdenticalScope,
331            section: RuleSection::Refactor,
332            title: "Merge adjacent @scope blocks",
333            category: "Structural Refactoring",
334            safety_level: SafetyLevel::ProvenLocalRefactor,
335            description: "Combine consecutive @scope blocks having identical root and limit parameters.",
336        },
337        RuleDefinition {
338            id: RuleId::MergeIdenticalStartingStyle,
339            section: RuleSection::Refactor,
340            title: "Merge adjacent @starting-style blocks",
341            category: "Structural Refactoring",
342            safety_level: SafetyLevel::ProvenLocalRefactor,
343            description: "Combine consecutive top-level @starting-style blocks into a single block.",
344        },
345        RuleDefinition {
346            id: RuleId::MergeAdjacentIdenticalSelector,
347            section: RuleSection::Refactor,
348            title: "Merge adjacent identical selectors",
349            category: "Structural Refactoring",
350            safety_level: SafetyLevel::ProvenLocalRefactor,
351            description: "Combine consecutive style rules sharing the exact same selector when no intervening rules exist.",
352        },
353        RuleDefinition {
354            id: RuleId::MergeIdenticalRuleBodies,
355            section: RuleSection::Refactor,
356            title: "Merge identical rule bodies",
357            category: "Structural Refactoring",
358            safety_level: SafetyLevel::ProvenLocalRefactor,
359            description: "Combine selectors sharing identical declaration bodies into a unified comma-separated rule.",
360        },
361        RuleDefinition {
362            id: RuleId::FactorIdenticalStatesWithIs,
363            section: RuleSection::Refactor,
364            title: "Factor identical states with :is()",
365            category: "Structural Refactoring",
366            safety_level: SafetyLevel::ProvenLocalRefactor,
367            description: "Combine multiple states of the same element sharing identical bodies into &:is(:hover, :focus, ...) form.",
368        },
369        RuleDefinition {
370            id: RuleId::GatherRelatedSelectorRules,
371            section: RuleSection::Refactor,
372            title: "Gather related selector rules",
373            category: "Structural Refactoring",
374            safety_level: SafetyLevel::SemanticReview,
375            description: "Gather scattered related rules into the strongest existing parent within the same cascade layer (specificity wins; prefix nest beats appended `&` on a tie). Busy @media/@supports blocks with mixed selectors stay grouped. Does not move declarations between named layers.",
376        },
377        RuleDefinition {
378            id: RuleId::DedupeIdenticalDeclarations,
379            section: RuleSection::Refactor,
380            title: "Dedupe identical declarations",
381            category: "Structural Refactoring",
382            safety_level: SafetyLevel::ProvenLocalRefactor,
383            description: "Remove exact-duplicate declarations and identical nested rule copies in the same block, keeping the first occurrence. Different values are left untouched so cascade order does not drift.",
384        },
385        RuleDefinition {
386            id: RuleId::NestLayerBySelector,
387            section: RuleSection::Refactor,
388            title: "Nest named layers under a shared selector",
389            category: "Structural Refactoring",
390            safety_level: SafetyLevel::SemanticReview,
391            description: "Factor the exact same selector living in multiple named @layer blocks into `.sel { @layer a { … } @layer b { … } }`, preserving layer identity and first-declared layer order. Never nests @layer inside another @layer (that would create a child layer).",
392        },
393        RuleDefinition {
394            id: RuleId::PruneOverriddenDeclarations,
395            section: RuleSection::Refactor,
396            title: "Prune overridden declarations & rules",
397            category: "Structural Refactoring",
398            safety_level: SafetyLevel::ProvenLocalRefactor,
399            description: "Remove dead declarations and entire rules overridden by later identical selectors in the cascade.",
400        },
401    ]
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "kebab-case")]
406pub enum Preset {
407    Analysis,
408    Conservative,
409    Modern,
410    Refactor,
411    Aggressive,
412    Custom,
413}
414
415impl Preset {
416    pub const ALL: [Preset; 6] = [
417        Preset::Analysis,
418        Preset::Conservative,
419        Preset::Modern,
420        Preset::Refactor,
421        Preset::Aggressive,
422        Preset::Custom,
423    ];
424
425    pub const fn label(self) -> &'static str {
426        match self {
427            Self::Analysis => "Analysis",
428            Self::Conservative => "Conservative",
429            Self::Modern => "Modern",
430            Self::Refactor => "Refactor",
431            Self::Aggressive => "Aggressive",
432            Self::Custom => "Custom",
433        }
434    }
435
436    pub fn enabled_rules(self) -> Vec<RuleId> {
437        match self {
438            Self::Analysis => vec![],
439            Self::Conservative => vec![
440                RuleId::NestPseudoClass,
441                RuleId::NestPseudoElement,
442                RuleId::NestAttribute,
443                RuleId::NestCompound,
444                RuleId::NestDescendant,
445                RuleId::NestCombinator,
446                RuleId::NestMedia,
447                RuleId::NestSupports,
448                RuleId::NestStartingStyle,
449                RuleId::FactorSelectorList,
450                RuleId::ModernizeIs,
451                RuleId::ModernizeMediaRange,
452                RuleId::MergeSameNamedLayer,
453                RuleId::MergeAdjacentMedia,
454                RuleId::MergeAdjacentSupports,
455                RuleId::MergeAdjacentIdenticalSelector,
456                RuleId::MergeIdenticalRuleBodies,
457                RuleId::FactorIdenticalStatesWithIs,
458                RuleId::DedupeIdenticalDeclarations,
459            ],
460            Self::Modern => vec![
461                RuleId::NestPseudoClass,
462                RuleId::NestPseudoElement,
463                RuleId::NestAttribute,
464                RuleId::NestCompound,
465                RuleId::NestDescendant,
466                RuleId::NestCombinator,
467                RuleId::NestMedia,
468                RuleId::NestSupports,
469                RuleId::NestContainer,
470                RuleId::NestStartingStyle,
471                RuleId::FactorSelectorList,
472                RuleId::ConsolidateNot,
473                RuleId::ModernizeIs,
474                RuleId::ModernizeMediaRange,
475                RuleId::DedupeIdenticalDeclarations,
476                RuleId::PruneOverriddenDeclarations,
477            ],
478            Self::Refactor => vec![
479                RuleId::NestPseudoClass,
480                RuleId::NestPseudoElement,
481                RuleId::NestAttribute,
482                RuleId::NestCompound,
483                RuleId::NestDescendant,
484                RuleId::NestCombinator,
485                RuleId::NestMedia,
486                RuleId::NestSupports,
487                RuleId::NestContainer,
488                RuleId::NestStartingStyle,
489                RuleId::FactorSelectorList,
490                RuleId::ConsolidateNot,
491                RuleId::ModernizeIs,
492                RuleId::ModernizeMediaRange,
493                RuleId::MergeSameNamedLayer,
494                RuleId::MergeAdjacentMedia,
495                RuleId::MergeAdjacentSupports,
496                RuleId::MergeAdjacentContainer,
497                RuleId::MergeIdenticalScope,
498                RuleId::MergeIdenticalStartingStyle,
499                RuleId::MergeAdjacentIdenticalSelector,
500                RuleId::MergeIdenticalRuleBodies,
501                RuleId::FactorIdenticalStatesWithIs,
502                RuleId::GatherRelatedSelectorRules,
503                RuleId::DedupeIdenticalDeclarations,
504                RuleId::NestLayerBySelector,
505                RuleId::PruneOverriddenDeclarations,
506            ],
507            Self::Aggressive => RuleId::ALL.to_vec(),
508            Self::Custom => vec![],
509        }
510    }
511}
512
513impl fmt::Display for Preset {
514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515        f.write_str(self.label())
516    }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "kebab-case")]
521pub enum OutputMode {
522    DryRun,
523    NewFile,
524    OutDir,
525    OverwriteWithBackup,
526    Overwrite,
527    Patch,
528    Stdout,
529}
530
531impl OutputMode {
532    pub const ALL: [OutputMode; 7] = [
533        Self::DryRun,
534        Self::NewFile,
535        Self::OutDir,
536        Self::OverwriteWithBackup,
537        Self::Overwrite,
538        Self::Patch,
539        Self::Stdout,
540    ];
541
542    pub const fn label(self) -> &'static str {
543        match self {
544            Self::DryRun => "Dry run",
545            Self::NewFile => "New file (*.modern.css)",
546            Self::OutDir => "Output directory",
547            Self::OverwriteWithBackup => "Overwrite + backup",
548            Self::Overwrite => "Overwrite",
549            Self::Patch => "Patch file",
550            Self::Stdout => "stdout",
551        }
552    }
553}
554
555impl fmt::Display for OutputMode {
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        f.write_str(self.label())
558    }
559}
560
561#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
562pub struct SourceRange {
563    pub start: usize,
564    pub end: usize,
565}
566
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct Proof {
569    pub selector_set_equivalent: bool,
570    pub specificity_equivalent: bool,
571    pub cascade_context_equivalent: bool,
572    pub source_order_equivalent: bool,
573    pub layer_equivalent: bool,
574    pub scope_equivalent: bool,
575    pub declarations_exact: bool,
576    pub important_exact: bool,
577}
578
579impl Proof {
580    pub fn safe_local() -> Self {
581        Self {
582            selector_set_equivalent: true,
583            specificity_equivalent: true,
584            cascade_context_equivalent: true,
585            source_order_equivalent: true,
586            layer_equivalent: true,
587            scope_equivalent: true,
588            declarations_exact: true,
589            important_exact: true,
590        }
591    }
592
593    pub fn all_pass(&self) -> bool {
594        self.selector_set_equivalent
595            && self.specificity_equivalent
596            && self.cascade_context_equivalent
597            && self.source_order_equivalent
598            && self.layer_equivalent
599            && self.scope_equivalent
600            && self.declarations_exact
601            && self.important_exact
602    }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct PlanEntry {
607    pub id: String,
608    pub file: PathBuf,
609    pub rules: Vec<RuleId>,
610    pub safety: Safety,
611    pub source_range: SourceRange,
612    pub original: String,
613    pub proposed: String,
614    pub proof: Proof,
615    pub warnings: Vec<String>,
616    pub reason: String,
617    #[serde(default = "default_true")]
618    pub selected: bool,
619}
620
621fn default_true() -> bool {
622    true
623}
624
625#[derive(Debug, Clone, Default, Serialize, Deserialize)]
626pub struct AnalysisStats {
627    pub bytes: usize,
628    pub top_level_style_rules: usize,
629    pub top_level_at_rules: usize,
630    pub declarations: usize,
631    pub important_declarations: usize,
632    pub custom_properties: usize,
633    pub duplicate_selectors: usize,
634    pub media_rules: usize,
635    pub supports_rules: usize,
636    pub container_rules: usize,
637    pub layer_rules: usize,
638    pub scope_rules: usize,
639    pub starting_style_rules: usize,
640    pub parse_errors: usize,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct Finding {
645    pub safety: Safety,
646    pub title: String,
647    pub detail: String,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct FileReport {
652    pub path: PathBuf,
653    pub parse_ok: bool,
654    pub parse_error: Option<String>,
655    pub stats: AnalysisStats,
656    pub findings: Vec<Finding>,
657    pub plans: Vec<PlanEntry>,
658}
659
660#[derive(Debug, Clone, Default, Serialize, Deserialize)]
661pub struct WorkspaceSummary {
662    pub files: usize,
663    pub parse_errors: usize,
664    pub rules_analyzed: usize,
665    pub safe: usize,
666    pub review: usize,
667    pub unsafe_count: usize,
668    pub unsupported: usize,
669    pub no_op: usize,
670    pub specificity_sensitive: usize,
671    pub cascade_sensitive: usize,
672    pub layer_sensitive: usize,
673    pub scope_sensitive: usize,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct WorkspaceReport {
678    pub tool_version: String,
679    pub spec_baseline: String,
680    pub root: PathBuf,
681    pub enabled_rules: Vec<RuleId>,
682    pub files: Vec<FileReport>,
683    pub summary: WorkspaceSummary,
684}