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