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