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