Skip to main content

krypt_core/config/
parse.rs

1//! `.krypt.toml` parser + semantic validator.
2//!
3//! The TOML deserializer already covers syntax errors, type mismatches, and
4//! (thanks to `deny_unknown_fields`) typo'd keys. On top of that we run a
5//! semantic pass that enforces invariants the type system can't:
6//!
7//! - exactly one of `src` / `src_glob` on each `[[link]]`
8//! - exactly one of `run` / `pipe` / `notify` on each step
9//! - platform strings are one of `linux` / `macos` / `windows`
10//! - prompt field `requires` references a key that exists in the same section
11
12use std::path::{Path, PathBuf};
13use std::{fs, io};
14
15use thiserror::Error;
16
17use super::schema::{Config, Link, PromptSection, Step};
18
19/// Errors that can come out of parsing or validating a `.krypt.toml`.
20#[derive(Debug, Error)]
21pub enum ConfigError {
22    /// Failed to read the file from disk.
23    #[error("read {path}: {source}")]
24    Io {
25        /// The path we tried to read.
26        path: PathBuf,
27        /// Underlying I/O error.
28        #[source]
29        source: io::Error,
30    },
31
32    /// Syntax / type / unknown-field error from the TOML deserializer.
33    /// The inner error already carries a span for nice error reporting.
34    #[error("{path}: {source}")]
35    Toml {
36        /// File that failed to parse.
37        path: PathBuf,
38        /// Underlying TOML error.
39        #[source]
40        source: Box<toml::de::Error>,
41    },
42
43    /// Semantic validation failure. Includes a JSON-pointer-ish location
44    /// hint so the user can find the offending entry.
45    #[error("{path}: {location}: {message}")]
46    Validation {
47        /// File that failed validation.
48        path: PathBuf,
49        /// Where in the doc the problem is (e.g. `link[2]`, `prompts.git.fields[0]`).
50        location: String,
51        /// Human-readable explanation.
52        message: String,
53    },
54}
55
56/// Parse a `.krypt.toml` from disk.
57///
58/// Runs syntactic parse + semantic validation. The returned `Config` is
59/// ready to feed into the include pass (#10) and path resolver (#11).
60pub fn parse_file(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
61    let path = path.as_ref();
62    let raw = fs::read_to_string(path).map_err(|source| ConfigError::Io {
63        path: path.to_owned(),
64        source,
65    })?;
66    parse_with_path(&raw, path)
67}
68
69/// Parse from an in-memory string. Useful for tests and `include` expansion
70/// where we already have the bytes. `path_hint` shows up in error messages
71/// when the source isn't an on-disk file.
72pub fn parse_str(raw: &str, path_hint: impl AsRef<Path>) -> Result<Config, ConfigError> {
73    parse_with_path(raw, path_hint.as_ref())
74}
75
76fn parse_with_path(raw: &str, path: &Path) -> Result<Config, ConfigError> {
77    let cfg: Config = toml::from_str(raw).map_err(|e| ConfigError::Toml {
78        path: path.to_owned(),
79        source: Box::new(e),
80    })?;
81    validate(&cfg, path)?;
82    Ok(cfg)
83}
84
85/// Semantic validation pass.
86///
87/// Type-system-enforceable invariants are checked by serde; this catches
88/// rules the type system can't express.
89fn validate(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
90    for (idx, link) in cfg.links.iter().enumerate() {
91        validate_link(link, &format!("link[{idx}]"), path)?;
92    }
93
94    for (idx, t) in cfg.templates.iter().enumerate() {
95        validate_platform(&t.platform, &format!("template[{idx}]"), path)?;
96    }
97
98    for (name, section) in &cfg.prompts {
99        validate_prompt_section(section, &format!("prompts.{name}"), path)?;
100    }
101
102    for (idx, hook) in cfg.hooks.iter().enumerate() {
103        if hook.run.is_empty() {
104            return Err(ConfigError::Validation {
105                path: path.to_owned(),
106                location: format!("hook[{idx}]"),
107                message: "`run` must contain at least one argument".into(),
108            });
109        }
110    }
111
112    for (idx, cmd) in cfg.commands.iter().enumerate() {
113        let loc = format!("command[{idx}] ({}/{})", cmd.group, cmd.name);
114        validate_platform(&cmd.platform, &loc, path)?;
115        if cmd.steps.is_empty() {
116            return Err(ConfigError::Validation {
117                path: path.to_owned(),
118                location: loc,
119                message: "command must have at least one step".into(),
120            });
121        }
122        for (sidx, step) in cmd.steps.iter().enumerate() {
123            validate_step(step, &format!("{loc}.steps[{sidx}]"), path)?;
124        }
125    }
126
127    Ok(())
128}
129
130fn validate_link(link: &Link, loc: &str, path: &Path) -> Result<(), ConfigError> {
131    match (link.src.as_deref(), link.src_glob.as_deref()) {
132        (Some(_), Some(_)) => Err(ConfigError::Validation {
133            path: path.to_owned(),
134            location: loc.into(),
135            message: "set exactly one of `src` or `src_glob`, not both".into(),
136        }),
137        (None, None) => Err(ConfigError::Validation {
138            path: path.to_owned(),
139            location: loc.into(),
140            message: "missing `src` or `src_glob`".into(),
141        }),
142        _ => Ok(()),
143    }?;
144    validate_platform(&link.platform, loc, path)
145}
146
147fn validate_platform(platform: &Option<String>, loc: &str, path: &Path) -> Result<(), ConfigError> {
148    if let Some(p) = platform
149        && !matches!(p.as_str(), "linux" | "macos" | "windows")
150    {
151        return Err(ConfigError::Validation {
152            path: path.to_owned(),
153            location: loc.into(),
154            message: format!("platform = {p:?} is not one of \"linux\" / \"macos\" / \"windows\""),
155        });
156    }
157    Ok(())
158}
159
160fn validate_prompt_section(
161    section: &PromptSection,
162    loc: &str,
163    path: &Path,
164) -> Result<(), ConfigError> {
165    if section.fields.is_empty() {
166        return Err(ConfigError::Validation {
167            path: path.to_owned(),
168            location: loc.into(),
169            message: "prompt section must have at least one field".into(),
170        });
171    }
172    let known: std::collections::HashSet<&str> =
173        section.fields.iter().map(|f| f.key.as_str()).collect();
174    for (idx, field) in section.fields.iter().enumerate() {
175        if let Some(req) = &field.requires
176            && !known.contains(req.as_str())
177        {
178            return Err(ConfigError::Validation {
179                path: path.to_owned(),
180                location: format!("{loc}.fields[{idx}]"),
181                message: format!(
182                    "`requires = \"{req}\"` references a field that doesn't exist in this section"
183                ),
184            });
185        }
186        if !matches!(field.r#type.as_str(), "string" | "bool" | "int") {
187            return Err(ConfigError::Validation {
188                path: path.to_owned(),
189                location: format!("{loc}.fields[{idx}]"),
190                message: format!(
191                    "type = {:?} is not one of \"string\" / \"bool\" / \"int\"",
192                    field.r#type
193                ),
194            });
195        }
196    }
197    Ok(())
198}
199
200fn validate_step(step: &Step, loc: &str, path: &Path) -> Result<(), ConfigError> {
201    let kinds = [
202        ("run", step.run.is_some()),
203        ("pipe", step.pipe.is_some()),
204        ("notify", step.notify.is_some()),
205    ];
206    let set: Vec<&str> = kinds
207        .iter()
208        .filter(|(_, has)| *has)
209        .map(|(n, _)| *n)
210        .collect();
211    if set.is_empty() {
212        return Err(ConfigError::Validation {
213            path: path.to_owned(),
214            location: loc.into(),
215            message: "step must set one of `run`, `pipe`, or `notify`".into(),
216        });
217    }
218    if set.len() > 1 {
219        return Err(ConfigError::Validation {
220            path: path.to_owned(),
221            location: loc.into(),
222            message: format!(
223                "step has multiple kinds set ({}); pick exactly one",
224                set.join(", ")
225            ),
226        });
227    }
228
229    // notify[0] = title, notify[1] = body. Allow 1- or 2-element form.
230    if let Some(n) = &step.notify
231        && (n.is_empty() || n.len() > 2)
232    {
233        return Err(ConfigError::Validation {
234            path: path.to_owned(),
235            location: loc.into(),
236            message: format!("`notify` takes 1 or 2 strings, got {}", n.len()),
237        });
238    }
239
240    if let Some(of) = &step.on_fail
241        && !matches!(of.as_str(), "abort" | "notify" | "ignore" | "prompt")
242    {
243        return Err(ConfigError::Validation {
244            path: path.to_owned(),
245            location: loc.into(),
246            message: format!(
247                "on_fail = {of:?} is not one of \"abort\" / \"notify\" / \"ignore\" / \"prompt\""
248            ),
249        });
250    }
251
252    Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn ok(s: &str) -> Config {
260        parse_str(s, "test.toml").expect("parse + validate should succeed")
261    }
262
263    fn err(s: &str) -> ConfigError {
264        parse_str(s, "test.toml").expect_err("expected an error")
265    }
266
267    #[test]
268    fn empty_file_is_valid_with_defaults() {
269        let cfg = ok("");
270        assert!(cfg.links.is_empty());
271        assert!(cfg.prompts.is_empty());
272    }
273
274    #[test]
275    fn link_requires_one_of_src_or_src_glob() {
276        let e = err(r#"
277[[link]]
278dst = "/tmp/x"
279"#);
280        assert!(matches!(e, ConfigError::Validation { .. }));
281    }
282
283    #[test]
284    fn link_rejects_both_src_and_src_glob() {
285        let e = err(r#"
286[[link]]
287src = "a"
288src_glob = "b/*"
289dst = "/tmp/x"
290"#);
291        match e {
292            ConfigError::Validation { message, .. } => {
293                assert!(message.contains("exactly one"), "got: {message}");
294            }
295            other => panic!("expected Validation, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn platform_must_be_known() {
301        let e = err(r#"
302[[link]]
303src = "a"
304dst = "/tmp/x"
305platform = "freebsd"
306"#);
307        assert!(matches!(e, ConfigError::Validation { .. }));
308    }
309
310    #[test]
311    fn unknown_top_level_field_is_loud() {
312        let e = err(r#"
313made_up_field = "oops"
314"#);
315        // serde's deny_unknown_fields → TOML error, not Validation
316        assert!(matches!(e, ConfigError::Toml { .. }));
317    }
318
319    #[test]
320    fn step_requires_exactly_one_kind() {
321        let e = err(r#"
322[[command]]
323group = "x"
324name = "y"
325steps = [
326  { capture = "z" }
327]
328"#);
329        assert!(matches!(e, ConfigError::Validation { .. }));
330    }
331
332    #[test]
333    fn step_rejects_multiple_kinds() {
334        let e = err(r#"
335[[command]]
336group = "x"
337name = "y"
338steps = [
339  { run = ["echo"], pipe = ["cat"] }
340]
341"#);
342        match e {
343            ConfigError::Validation { message, .. } => {
344                assert!(message.contains("multiple kinds"), "got: {message}");
345            }
346            other => panic!("expected Validation, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn prompt_requires_must_reference_known_field() {
352        let e = err(r#"
353[prompts.x]
354writer = "noop"
355fields = [
356  { key = "a", prompt = "Aye?", requires = "nonexistent" },
357]
358"#);
359        assert!(matches!(e, ConfigError::Validation { .. }));
360    }
361
362    #[test]
363    fn full_round_trip() {
364        let cfg = ok(r#"
365include = ["other.toml"]
366
367[meta]
368name = "test"
369krypt_min = "0.1.0"
370
371[paths]
372HOME = "${env:HOME}"
373
374[[link]]
375src = ".gitconfig"
376dst = "${HOME}/.gitconfig"
377
378[[link]]
379src_glob = ".config/nvim/**/*"
380dst = "${HOME}/.config/nvim/"
381platform = "linux"
382
383[[template]]
384src = ".gitconfig.local.template"
385dst = "${HOME}/.gitconfig.local"
386prompts = ["git"]
387
388[prompts.git]
389heading = "Git identity"
390writer = "gitconfig"
391fields = [
392  { key = "name",  prompt = "Your name" },
393  { key = "email", prompt = "Your email" },
394  { key = "key",   prompt = "GPG key", optional = true, default_from = "field:email" },
395  { key = "sign",  prompt = "Sign commits?", type = "bool", default = false, requires = "key" },
396]
397
398[[deps]]
399group = "core"
400pacman = ["alacritty", "fish"]
401brew   = ["alacritty", "fish"]
402
403[[hook]]
404name = "fisher"
405when = "post-update"
406if   = "command_exists:fish"
407run  = ["fish", "-c", "fisher update"]
408ignore_failure = true
409
410[[command]]
411group = "menu"
412name  = "wifi"
413platform = "linux"
414description = "Pick + connect to a WiFi network"
415steps = [
416  { run = ["nmcli", "-t", "device", "wifi", "list"], capture = "list" },
417  { pipe = ["rofi", "-dmenu"], input = "{list}", capture = "ssid" },
418  { run = ["nmcli", "device", "wifi", "connect", "{ssid}"], on_fail = "notify" },
419]
420"#);
421        assert_eq!(cfg.meta.name, "test");
422        assert_eq!(cfg.links.len(), 2);
423        assert_eq!(cfg.templates.len(), 1);
424        assert_eq!(cfg.prompts["git"].fields.len(), 4);
425        assert_eq!(cfg.commands.len(), 1);
426        assert_eq!(cfg.commands[0].steps.len(), 3);
427    }
428}