Skip to main content

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";