dictator_decree_abi/lib.rs
1#![warn(rust_2024_compatibility, clippy::all)]
2
3use serde::{Deserialize, Serialize};
4
5/// ABI version for decree compatibility checking.
6///
7/// Bumped when Plugin trait or core types change.
8/// Pre-1.0: exact major.minor match required (0.1.x ↔ 0.1.y ✓, 0.1.x ↔ 0.2.y ✗)
9/// Post-1.0: major must match, decree minor ≤ host minor
10pub const ABI_VERSION: &str = "0.2.0";
11
12/// Capability flags for decrees
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum Capability {
15 /// Basic linting (always required)
16 Lint,
17 /// Can auto-fix issues
18 AutoFix,
19 /// Supports incremental/streaming linting
20 Streaming,
21 /// Accepts config at lint-time
22 RuntimeConfig,
23 /// Returns enhanced diagnostics (quickfixes, etc.)
24 RichDiagnostics,
25}
26
27/// Fallback persona for decrees that don't declare one (e.g. older WASM
28/// plugins compiled before the `persona` field existed).
29fn default_persona() -> String {
30 "The Dictator".to_string()
31}
32
33/// Metadata for decree versioning and capabilities
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DecreeMetadata {
36 /// ABI version this decree was built against
37 pub abi_version: String,
38 /// Decree's own version (e.g., "0.60.0" for kjr)
39 pub decree_version: String,
40 /// Human-readable description
41 pub description: String,
42 /// The named persona that speaks for this decree in diagnostics
43 /// (e.g. FreeBSD -> "Beastie", Rust -> "Ferris").
44 #[serde(default = "default_persona")]
45 pub persona: String,
46 /// Decree authors (from workspace, optional)
47 pub dectauthors: Option<String>,
48 /// File extensions this decree handles (e.g., `["rb", "rake"]`)
49 pub supported_extensions: Vec<String>,
50 /// Exact filenames this decree handles (e.g., `["Gemfile", "Rakefile"]`)
51 #[serde(default)]
52 pub supported_filenames: Vec<String>,
53 /// Filenames to claim but NOT lint (lock files, generated files)
54 /// Decree owns these to prevent other decrees from touching them.
55 #[serde(default)]
56 pub skip_filenames: Vec<String>,
57 /// Rules that describe the whole file, not a line. Reported by `--diff`
58 /// whenever the file is touched, since their span is pinned to 0 or EOF.
59 /// Bare names, no decree prefix: `"file-too-long"`.
60 #[serde(default)]
61 pub file_scope_rules: Vec<String>,
62 /// Capabilities this decree provides
63 pub capabilities: Vec<Capability>,
64}
65
66impl DecreeMetadata {
67 /// Check if this decree has a specific capability.
68 #[must_use]
69 pub fn has_capability(&self, cap: Capability) -> bool {
70 self.capabilities.contains(&cap)
71 }
72
73 /// Is `rule` (`"rust/file-too-long"`) declared file-scoped?
74 #[must_use]
75 pub fn is_file_scope(&self, rule: &str) -> bool {
76 let bare = rule.rsplit('/').next().unwrap_or(rule);
77 self.file_scope_rules.iter().any(|known| known == bare)
78 }
79
80 /// Parse semver version string.
81 ///
82 /// # Errors
83 ///
84 /// Returns an error if the version string is not in the format "major.minor.patch"
85 /// or if any component cannot be parsed as a u32.
86 pub fn parse_version(version: &str) -> Result<(u32, u32, u32), String> {
87 let parts: Vec<&str> = version.split('.').collect();
88 if parts.len() != 3 {
89 return Err(format!("invalid version format: {version}"));
90 }
91 let major = parts[0]
92 .parse()
93 .map_err(|_| format!("invalid major: {}", parts[0]))?;
94 let minor = parts[1]
95 .parse()
96 .map_err(|_| format!("invalid minor: {}", parts[1]))?;
97 let patch = parts[2]
98 .parse()
99 .map_err(|_| format!("invalid patch: {}", parts[2]))?;
100 Ok((major, minor, patch))
101 }
102
103 /// Check if this decree's ABI version is compatible with host ABI version.
104 ///
105 /// # Errors
106 ///
107 /// Returns an error if the ABI versions are incompatible or if version parsing fails.
108 pub fn validate_abi(&self, host_abi_version: &str) -> Result<(), String> {
109 let (host_maj, host_min, _) = Self::parse_version(host_abi_version)?;
110 let (decree_maj, decree_min, _) = Self::parse_version(&self.abi_version)?;
111
112 // Pre-1.0: exact major.minor match required
113 if host_maj == 0 {
114 if host_maj == decree_maj && host_min == decree_min {
115 return Ok(());
116 }
117 return Err(format!(
118 "ABI version mismatch: host {}, decree {}",
119 host_abi_version, self.abi_version
120 ));
121 }
122
123 // Post-1.0: major must match, decree minor ≤ host minor
124 if host_maj == decree_maj && decree_min <= host_min {
125 return Ok(());
126 }
127
128 Err(format!(
129 "ABI version incompatible: host {}, decree {}",
130 host_abi_version, self.abi_version
131 ))
132 }
133}
134
135/// Byte offsets into the source file (half-open range).
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137pub struct Span {
138 pub start: usize,
139 pub end: usize,
140}
141
142impl Span {
143 /// Create a new span with the given start and end offsets.
144 #[must_use]
145 pub const fn new(start: usize, end: usize) -> Self {
146 Self { start, end }
147 }
148
149 /// Check if this span is empty (start >= end).
150 #[must_use]
151 pub const fn is_empty(&self) -> bool {
152 self.start >= self.end
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Diagnostic {
158 /// Rule identifier, e.g. "ruby/trailing-whitespace".
159 pub rule: String,
160 pub message: String,
161 pub span: Span,
162 /// true = Dictator enforced (auto-fixed), false = you must comply
163 pub enforced: bool,
164}
165
166pub type Diagnostics = Vec<Diagnostic>;
167
168/// Trait all Dictator decrees must implement. Designed to be usable from WASM by
169/// exporting a thin C-ABI shim that instantiates a concrete implementor.
170pub trait Decree: Send + Sync {
171 /// Human-friendly decree name, e.g. "ruby".
172 #[must_use]
173 fn name(&self) -> &str;
174
175 /// Lint a single file, returning diagnostics. `path` is UTF-8.
176 fn lint(&self, path: &str, source: &str) -> Diagnostics;
177
178 /// Metadata for versioning and capabilities.
179 #[must_use]
180 fn metadata(&self) -> DecreeMetadata;
181
182 /// Create rule identifier: `{decree}/{rule}` - DRY helper.
183 #[must_use]
184 fn rule(&self, rule_name: &str) -> String {
185 format!("{}/{}", self.name(), rule_name)
186 }
187}
188
189/// Boxed decree for dynamic dispatch.
190pub type BoxDecree = Box<dyn Decree>;
191
192/// Function exported by WASM decrees to construct an instance.
193pub type DecreeFactory = fn() -> BoxDecree;
194
195/// Export name expected in decrees for the factory symbol.
196pub const DECREE_FACTORY_EXPORT: &str = "dictator_create_decree";