Skip to main content

plumb_config/
lib.rs

1//! # plumb-config
2//!
3//! Config loading + JSON Schema emission for Plumb.
4//!
5//! Accepts TOML, YAML, or JSON on disk; emits a single JSON Schema for
6//! editor autocompletion.
7
8#![forbid(unsafe_code)]
9#![deny(missing_docs)]
10#![deny(clippy::unwrap_used, clippy::expect_used)]
11
12use std::fs;
13use std::ops::Range;
14use std::path::Path;
15
16use figment::Figment;
17use figment::providers::{Format, Json, Yaml};
18use miette::{Diagnostic, NamedSource, SourceSpan};
19use plumb_core::Config;
20use thiserror::Error;
21
22mod css_props;
23mod dtcg;
24mod span;
25pub mod tailwind;
26mod validate;
27
28pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties};
29pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg};
30
31use span::{SourceFormat, locate_path};
32pub use tailwind::{TailwindOptions, merge_tailwind};
33use validate::ValidationIssue;
34
35/// Underlying config parse errors.
36///
37/// Both variants forward Display **and** `source()` to the inner error
38/// via `#[error(transparent)]`. Without this, an `anyhow::Error::chain`
39/// over a `ConfigError::Parse` would print the toml/figment error
40/// twice — once from this enum's `"{0}"` display and once from the
41/// chained inner — producing duplicated span-annotated blocks on a
42/// single bad-TOML file.
43#[derive(Debug, Error)]
44#[non_exhaustive]
45pub enum ConfigParseSource {
46    /// TOML parser or schema error.
47    #[error(transparent)]
48    Toml(#[from] toml::de::Error),
49    /// Figment parser or schema error.
50    #[error(transparent)]
51    Figment(#[from] figment::Error),
52}
53
54/// Config-loading errors.
55#[derive(Debug, Error, Diagnostic)]
56#[non_exhaustive]
57pub enum ConfigError {
58    /// File extension isn't one we recognize.
59    #[error("unsupported config extension `{0}` (expected .toml, .yaml, .yml, or .json)")]
60    UnsupportedExtension(String),
61    /// The file is missing.
62    #[error("config file not found: {0}")]
63    NotFound(String),
64    /// The file exists but could not be read.
65    #[error("failed to read config file `{path}`: {source}")]
66    Read {
67        /// Path that failed to read.
68        path: String,
69        /// Underlying I/O error.
70        #[source]
71        source: std::io::Error,
72    },
73    /// The file exists but couldn't be parsed or the content didn't
74    /// match the config schema.
75    ///
76    /// The Display message intentionally omits `{source}` — the chained
77    /// [`ConfigParseSource`] (transparent) prints the underlying toml /
78    /// figment span block once via the standard
79    /// `Error::source()` walk. Embedding `{source}` here would
80    /// duplicate the span block in `anyhow::Error::chain` output.
81    #[error("failed to parse config file `{path}`")]
82    #[diagnostic(code(plumb::config::parse))]
83    Parse {
84        /// Path that failed to parse.
85        path: String,
86        /// Underlying parse error.
87        #[source]
88        source: Box<ConfigParseSource>,
89        /// Source text for span-annotated diagnostics.
90        #[source_code]
91        source_code: Option<NamedSource<String>>,
92        /// Label pointing at the invalid config span, when available.
93        #[label("invalid config")]
94        span: Option<SourceSpan>,
95    },
96    /// The file parsed structurally but failed semantic validation
97    /// (e.g. a palette token whose value isn't a hex color).
98    #[error("invalid config value at `{value_path}` in `{path}`: {message}")]
99    #[diagnostic(code(plumb::config::validation))]
100    Validation {
101        /// Path of the file that failed validation.
102        path: String,
103        /// Dotted path of the offending value (e.g. `color.tokens.bg`).
104        value_path: String,
105        /// Why the value is invalid.
106        message: String,
107        /// Source text for span-annotated diagnostics.
108        #[source_code]
109        source_code: Option<NamedSource<String>>,
110        /// Label pointing at the offending value, when the source format
111        /// allows span recovery.
112        #[label("invalid value")]
113        span: Option<SourceSpan>,
114    },
115    /// A CSS source (e.g. a token sheet passed to
116    /// [`scrape_css_properties`]) was malformed and could not be scanned.
117    #[error("failed to parse CSS file `{path}`: {message}")]
118    #[diagnostic(code(plumb::config::css_parse))]
119    CssParse {
120        /// Path of the file that failed to parse.
121        path: String,
122        /// Human-readable description of the offending region.
123        message: String,
124        /// Source text for span-annotated diagnostics.
125        #[source_code]
126        source_code: Option<NamedSource<String>>,
127        /// Label pointing at the offending region.
128        #[label("invalid CSS")]
129        span: Option<SourceSpan>,
130    },
131    /// Schema emission failed.
132    #[error("failed to emit schema: {0}")]
133    Schema(#[source] serde_json::Error),
134    /// A DTCG import failed at the parsing or value-conversion stage.
135    #[error("failed to import DTCG token file `{path}`: {reason}")]
136    #[diagnostic(code(plumb::config::dtcg_parse))]
137    DtcgParse {
138        /// Path of the failing DTCG document.
139        path: String,
140        /// Source text for span-annotated diagnostics.
141        #[source_code]
142        source_code: Option<NamedSource<String>>,
143        /// Best-effort label location, when the parser could pin one.
144        #[label("invalid token")]
145        span: Option<SourceSpan>,
146        /// Human-readable explanation.
147        reason: String,
148    },
149    /// A DTCG alias either dangles or forms a cycle.
150    #[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")]
151    #[diagnostic(code(plumb::config::dtcg_alias))]
152    DtcgAlias {
153        /// Path of the DTCG document where the alias error was raised.
154        path: String,
155        /// Source text for span-annotated diagnostics.
156        #[source_code]
157        source_code: Option<NamedSource<String>>,
158        /// Slash-joined token paths in the order the resolver visited them.
159        cycle: Vec<String>,
160        /// Human-readable explanation.
161        reason: String,
162    },
163    /// The Tailwind adapter could not run because Node is missing
164    /// or otherwise unavailable.
165    #[error("tailwind adapter unavailable: {reason}")]
166    #[diagnostic(
167        code(plumb::config::tailwind_unavailable),
168        help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
169    )]
170    TailwindUnavailable {
171        /// Why the adapter couldn't run (missing Node, missing override, …).
172        reason: String,
173    },
174    /// The Tailwind config path is malformed or escapes the project tree.
175    #[error("invalid tailwind config path `{path}`: {reason}")]
176    #[diagnostic(code(plumb::config::tailwind_bad_path))]
177    TailwindBadPath {
178        /// User-supplied tailwind config path.
179        path: String,
180        /// Why we rejected it.
181        reason: String,
182    },
183    /// Node ran but the Tailwind config evaluation failed.
184    #[error("failed to evaluate tailwind config `{path}`: {reason}")]
185    #[diagnostic(code(plumb::config::tailwind_eval))]
186    TailwindEval {
187        /// User-supplied tailwind config path.
188        path: String,
189        /// Reason text — either the loader's structured error or the
190        /// shape of the subprocess failure.
191        reason: String,
192        /// Captured stderr from the Node subprocess. Empty when the
193        /// child closed without writing diagnostics.
194        stderr: String,
195    },
196}
197
198/// Load a `Config` from disk. The file extension decides the parser.
199///
200/// # Errors
201///
202/// Returns [`ConfigError::NotFound`] if the file is missing,
203/// [`ConfigError::UnsupportedExtension`] if the extension is unrecognized,
204/// [`ConfigError::Parse`] if structural parsing fails, or
205/// [`ConfigError::Validation`] if a value fails semantic validation
206/// (e.g. a non-hex palette token).
207pub fn load(path: &Path) -> Result<Config, ConfigError> {
208    if !path.exists() {
209        return Err(ConfigError::NotFound(path.display().to_string()));
210    }
211
212    let ext = path
213        .extension()
214        .and_then(|e| e.to_str())
215        .unwrap_or("")
216        .to_ascii_lowercase();
217
218    let (config, contents, format) = match ext.as_str() {
219        "toml" => {
220            let (cfg, body) = load_toml(path)?;
221            (cfg, body, SourceFormat::Toml)
222        }
223        "yaml" | "yml" => {
224            let (cfg, body) = load_yaml(path)?;
225            (cfg, body, SourceFormat::Yaml)
226        }
227        "json" => {
228            let (cfg, body) = load_json(path)?;
229            (cfg, body, SourceFormat::Json)
230        }
231        other => return Err(ConfigError::UnsupportedExtension(other.to_owned())),
232    };
233
234    if let Some(issue) = validate::validate(&config) {
235        return Err(validation_error(path, contents, format, issue));
236    }
237
238    Ok(config)
239}
240
241fn validation_error(
242    path: &Path,
243    contents: String,
244    format: SourceFormat,
245    issue: ValidationIssue,
246) -> ConfigError {
247    let span = locate_path(&contents, format, &issue.path_segments);
248    let language = match format {
249        SourceFormat::Toml => "toml",
250        SourceFormat::Yaml => "yaml",
251        SourceFormat::Json => "json",
252    };
253    ConfigError::Validation {
254        path: path.display().to_string(),
255        value_path: issue.path_segments.join("."),
256        message: issue.message,
257        source_code: Some(
258            NamedSource::new(path.display().to_string(), contents).with_language(language),
259        ),
260        span,
261    }
262}
263
264fn load_toml(path: &Path) -> Result<(Config, String), ConfigError> {
265    let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
266        path: path.display().to_string(),
267        source,
268    })?;
269
270    let parsed = toml::from_str::<Config>(&contents).map_err(|source| {
271        let span = source.span().and_then(source_span);
272        ConfigError::Parse {
273            path: path.display().to_string(),
274            source: Box::new(ConfigParseSource::Toml(source)),
275            source_code: Some(
276                NamedSource::new(path.display().to_string(), contents.clone())
277                    .with_language("toml"),
278            ),
279            span,
280        }
281    })?;
282
283    Ok((parsed, contents))
284}
285
286fn load_yaml(path: &Path) -> Result<(Config, String), ConfigError> {
287    let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
288        path: path.display().to_string(),
289        source,
290    })?;
291
292    let figment = Figment::new().merge(Yaml::file(path));
293    let cfg = figment
294        .extract::<Config>()
295        .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Yaml, source))?;
296    Ok((cfg, contents))
297}
298
299fn load_json(path: &Path) -> Result<(Config, String), ConfigError> {
300    let contents = fs::read_to_string(path).map_err(|source| ConfigError::Read {
301        path: path.display().to_string(),
302        source,
303    })?;
304
305    let figment = Figment::new().merge(Json::file(path));
306    let cfg = figment
307        .extract::<Config>()
308        .map_err(|source| build_figment_parse_error(path, &contents, SourceFormat::Json, source))?;
309    Ok((cfg, contents))
310}
311
312fn build_figment_parse_error(
313    path: &Path,
314    contents: &str,
315    format: SourceFormat,
316    source: figment::Error,
317) -> ConfigError {
318    let segments: Vec<String> = source.path.clone();
319    let span = if segments.is_empty() {
320        None
321    } else {
322        locate_path(contents, format, &segments)
323    };
324    let language = match format {
325        SourceFormat::Toml => "toml",
326        SourceFormat::Yaml => "yaml",
327        SourceFormat::Json => "json",
328    };
329    let display_path = config_error_path(&source).unwrap_or_else(|| path.display().to_string());
330    ConfigError::Parse {
331        path: display_path,
332        source: Box::new(ConfigParseSource::Figment(source)),
333        source_code: Some(
334            NamedSource::new(path.display().to_string(), contents.to_owned())
335                .with_language(language),
336        ),
337        span,
338    }
339}
340
341fn source_span(range: Range<usize>) -> Option<SourceSpan> {
342    let len = range.end.checked_sub(range.start)?;
343    Some((range.start, len).into())
344}
345
346fn config_error_path(source: &figment::Error) -> Option<String> {
347    source
348        .metadata
349        .as_ref()
350        .and_then(|metadata| metadata.source.as_ref())
351        .map(ToString::to_string)
352}
353
354/// Emit the JSON Schema for [`Config`] as a pretty-printed string.
355///
356/// # Errors
357///
358/// Returns [`ConfigError::Schema`] if JSON serialization fails.
359pub fn emit_schema() -> Result<String, ConfigError> {
360    let schema = schemars::schema_for!(Config);
361    serde_json::to_string_pretty(&schema).map_err(ConfigError::Schema)
362}