1use std::path::{Path, PathBuf};
13use std::{fs, io};
14
15use thiserror::Error;
16
17use super::schema::{Config, Link, PromptSection, Step};
18
19#[derive(Debug, Error)]
21pub enum ConfigError {
22 #[error("read {path}: {source}")]
24 Io {
25 path: PathBuf,
27 #[source]
29 source: io::Error,
30 },
31
32 #[error("{path}: {source}")]
35 Toml {
36 path: PathBuf,
38 #[source]
40 source: Box<toml::de::Error>,
41 },
42
43 #[error("{path}: {location}: {message}")]
46 Validation {
47 path: PathBuf,
49 location: String,
51 message: String,
53 },
54}
55
56pub 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
69pub 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
85fn 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 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 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}