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