lds_core/config.rs
1//! First-class configuration for lds.
2//!
3//! Reads and writes `~/.config/lds/config.toml` (or an explicit path).
4//! The primary design constraints are:
5//!
6//! 1. **patch-safe write** — `Config::save` uses `toml_edit` to update only
7//! the `recipes.dirs` array while preserving comments and unrelated sections.
8//! 2. **tilde expansion** — any path stored on disk must be an absolute path;
9//! tilde literals are never written to `config.toml`.
10
11use std::io;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16use toml_edit::{Array, DocumentMut, Item, Value};
17
18// ---------------------------------------------------------------------------
19// Error type
20// ---------------------------------------------------------------------------
21
22/// Errors that can occur during config load or save operations.
23#[derive(Debug, Error)]
24pub enum ConfigError {
25 /// An I/O error (e.g. permission denied, parent directory not found).
26 #[error("config I/O error: {0}")]
27 Io(#[from] io::Error),
28
29 /// TOML deserialization error (returned by `Config::load`).
30 #[error("config parse error: {0}")]
31 Parse(#[from] toml::de::Error),
32
33 /// `toml_edit` document-level error (returned by `Config::save`).
34 #[error("config edit error: {0}")]
35 Edit(#[from] toml_edit::TomlError),
36
37 /// TOML serialization error.
38 #[error("config serialize error: {0}")]
39 Serialize(#[from] toml::ser::Error),
40}
41
42// ---------------------------------------------------------------------------
43// Config structs
44// ---------------------------------------------------------------------------
45
46/// Top-level configuration for lds.
47///
48/// Deserializes from `~/.config/lds/config.toml`. Missing sections fall back
49/// to `Default` via `#[serde(default)]`.
50#[derive(Debug, Clone, Default, Deserialize, Serialize)]
51#[serde(default)]
52pub struct Config {
53 /// Recipe directory settings.
54 pub recipes: Recipes,
55 /// Path overrides.
56 pub paths: Paths,
57}
58
59/// Recipe-related configuration.
60#[derive(Debug, Clone, Default, Deserialize, Serialize)]
61#[serde(default)]
62pub struct Recipes {
63 /// Additional global recipe directories (highest priority source).
64 ///
65 /// Entries are absolute paths. Tilde is expanded on load and must be
66 /// absent from `config.toml` on disk.
67 pub dirs: Vec<PathBuf>,
68}
69
70/// Path overrides for well-known lds locations.
71#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72#[serde(default)]
73pub struct Paths {
74 /// Override for the global justfile path (default: `~/.config/lds/justfile`).
75 pub global_justfile: Option<PathBuf>,
76}
77
78// ---------------------------------------------------------------------------
79// tilde_expand
80// ---------------------------------------------------------------------------
81
82/// Expand a leading `~/` or lone `~` to the user's home directory.
83///
84/// # Arguments
85///
86/// * `input` — A path string that may start with `~/`.
87///
88/// # Returns
89///
90/// An absolute `PathBuf`. If `input` does not start with `~/` or `~`, it is
91/// returned as-is wrapped in `PathBuf`.
92///
93/// # Errors
94///
95/// Returns `ConfigError::Io(NotFound)` when the home directory cannot be
96/// determined (e.g. `$HOME` is unset on Unix).
97pub fn tilde_expand(input: &str) -> Result<PathBuf, ConfigError> {
98 if input == "~" {
99 let home = dirs::home_dir().ok_or_else(|| {
100 ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
101 })?;
102 Ok(home)
103 } else if let Some(rest) = input.strip_prefix("~/") {
104 let home = dirs::home_dir().ok_or_else(|| {
105 ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
106 })?;
107 Ok(home.join(rest))
108 } else {
109 Ok(PathBuf::from(input))
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Config impl
115// ---------------------------------------------------------------------------
116
117impl Config {
118 /// Load configuration from an explicit file path.
119 ///
120 /// # Arguments
121 ///
122 /// * `path` — Path to a TOML configuration file.
123 ///
124 /// # Returns
125 ///
126 /// A fully populated `Config`. Missing optional sections are filled with
127 /// `Default`.
128 ///
129 /// # Errors
130 ///
131 /// - `ConfigError::Io` if the file cannot be read.
132 /// - `ConfigError::Parse` if the TOML is malformed.
133 pub fn load(path: &Path) -> Result<Self, ConfigError> {
134 let content = std::fs::read_to_string(path)?;
135 let config: Config = toml::from_str(&content)?;
136 Ok(config)
137 }
138
139 /// Load configuration from the default path (`~/.config/lds/config.toml`).
140 ///
141 /// If the file does not exist this returns `Config::default()` silently.
142 /// Any other I/O error or parse error is also silently swallowed and the
143 /// default is returned — suitable for startup where a missing config is
144 /// expected to be common.
145 ///
146 /// # Returns
147 ///
148 /// A `Config`, falling back to `Default` on any error.
149 pub fn load_or_default() -> Self {
150 let Some(home) = dirs::home_dir() else {
151 return Self::default();
152 };
153 let path = home.join(".config/lds/config.toml");
154 match Self::load(&path) {
155 Ok(cfg) => cfg,
156 Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => Self::default(),
157 Err(e) => {
158 tracing::warn!("failed to load config from {}: {}", path.display(), e);
159 Self::default()
160 }
161 }
162 }
163
164 /// Save the `recipes.dirs` list to `path` using a **patch-safe** write.
165 ///
166 /// The file is parsed by `toml_edit` so that comments and sections not
167 /// managed by this function (e.g. `[paths]`) are preserved verbatim.
168 /// Only the `recipes.dirs` array is replaced.
169 ///
170 /// All paths in `dirs` must already be absolute (tilde-expanded before
171 /// calling this function). Passing a tilde literal is a logic error and
172 /// will be written literally — callers are responsible for expanding first.
173 ///
174 /// If the parent directory does not exist it is created with
175 /// `fs::create_dir_all`.
176 ///
177 /// # Arguments
178 ///
179 /// * `path` — Destination file (typically `~/.config/lds/config.toml`).
180 /// * `dirs` — Absolute paths to persist in `recipes.dirs`.
181 ///
182 /// # Errors
183 ///
184 /// - `ConfigError::Io` for I/O failures (create dir, read, write).
185 /// - `ConfigError::Edit` if the existing file is not valid TOML.
186 pub fn save(path: &Path, dirs: &[PathBuf]) -> Result<(), ConfigError> {
187 // Ensure parent directory exists.
188 if let Some(parent) = path.parent() {
189 std::fs::create_dir_all(parent)?;
190 }
191
192 // Read existing content (empty string when file is absent).
193 let existing = match std::fs::read_to_string(path) {
194 Ok(s) => s,
195 Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
196 Err(e) => return Err(ConfigError::Io(e)),
197 };
198
199 // Parse with toml_edit to preserve comments and unrelated sections.
200 let mut doc: DocumentMut = existing.parse::<DocumentMut>()?;
201
202 // Build a fresh TOML array from `dirs`.
203 let mut arr = Array::new();
204 for dir in dirs {
205 // Safety: PathBuf::to_string_lossy is infallible (may be lossy on
206 // non-UTF-8 systems, but that is acceptable given TOML's UTF-8 requirement).
207 arr.push(dir.to_string_lossy().as_ref());
208 }
209
210 // Write `recipes.dirs` — create intermediate tables as needed.
211 if !doc.contains_table("recipes") {
212 doc["recipes"] = toml_edit::table();
213 }
214 doc["recipes"]["dirs"] = Item::Value(Value::Array(arr));
215
216 std::fs::write(path, doc.to_string())?;
217 Ok(())
218 }
219}
220
221// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::fs;
229 use tempfile::TempDir;
230
231 // ------------------------------------------------------------------
232 // T1: happy-path / property tests
233 // ------------------------------------------------------------------
234
235 /// T1-a: round-trip — serialize a Config and read it back identically.
236 #[test]
237 fn test_round_trip_load_save() {
238 let dir = TempDir::new().unwrap(); // justification: TempDir::new is infallible in practice; any failure surfaces as a test setup panic which is acceptable in test code
239 let path = dir.path().join("config.toml");
240
241 let dirs_in = vec![
242 PathBuf::from("/opt/shared-recipes"),
243 PathBuf::from("/home/user/team-recipes"),
244 ];
245
246 Config::save(&path, &dirs_in).expect("save should succeed");
247 let cfg = Config::load(&path).expect("load should succeed");
248
249 assert_eq!(cfg.recipes.dirs, dirs_in);
250 }
251
252 /// T1-b: load_or_default returns Default when no file exists.
253 #[test]
254 fn test_load_or_default_missing_file() {
255 // Temporarily override HOME to a directory with no config.toml.
256 let dir = TempDir::new().unwrap(); // justification: same as above
257 // We cannot easily unset HOME in a portable way, so we test Config::load
258 // directly with a non-existent path to exercise the NotFound branch.
259 let path = dir.path().join("nonexistent/config.toml");
260 match Config::load(&path) {
261 Err(ConfigError::Io(e)) => {
262 assert_eq!(e.kind(), io::ErrorKind::NotFound);
263 }
264 other => panic!("expected Io(NotFound), got {:?}", other),
265 }
266 }
267
268 /// T1-c: tilde_expand returns an absolute path for a ~/... input.
269 #[test]
270 fn test_tilde_expand_tilde_slash() {
271 // Only run when HOME is available.
272 if dirs::home_dir().is_none() {
273 return;
274 }
275 let result = tilde_expand("~/foo/bar").expect("tilde_expand should succeed");
276 let home = dirs::home_dir().unwrap(); // justification: we just checked it is Some above
277 assert_eq!(result, home.join("foo/bar"));
278 }
279
280 /// T1-d: tilde_expand with bare `~`.
281 #[test]
282 fn test_tilde_expand_bare_tilde() {
283 if dirs::home_dir().is_none() {
284 return;
285 }
286 let result = tilde_expand("~").expect("bare tilde should expand");
287 let home = dirs::home_dir().unwrap(); // justification: checked is Some above
288 assert_eq!(result, home);
289 }
290
291 // ------------------------------------------------------------------
292 // T2: boundary / edge-case tests
293 // ------------------------------------------------------------------
294
295 /// T2-a: empty dirs list produces empty `recipes.dirs` array.
296 #[test]
297 fn test_save_empty_dirs() {
298 let dir = TempDir::new().unwrap(); // justification: test setup
299 let path = dir.path().join("config.toml");
300
301 Config::save(&path, &[]).expect("save should succeed");
302 let cfg = Config::load(&path).expect("load should succeed");
303 assert!(cfg.recipes.dirs.is_empty());
304 }
305
306 /// T2-b: load_or_default on truly missing file via `Config::load` NotFound.
307 #[test]
308 fn test_load_or_default_does_not_panic_on_missing() {
309 // Exercise the public load_or_default by calling it; if HOME is not
310 // set or the file is absent it returns Default without panic.
311 let _cfg = Config::load_or_default();
312 // No assertion needed — absence of panic is the contract.
313 }
314
315 /// T2-c: tilde_expand with no tilde passes through unchanged.
316 #[test]
317 fn test_tilde_expand_no_tilde() {
318 let result = tilde_expand("/absolute/path").expect("should succeed");
319 assert_eq!(result, PathBuf::from("/absolute/path"));
320 }
321
322 /// T2-d: tilde_expand with a relative path (no tilde) passes through.
323 #[test]
324 fn test_tilde_expand_relative() {
325 let result = tilde_expand("relative/path").expect("should succeed");
326 assert_eq!(result, PathBuf::from("relative/path"));
327 }
328
329 /// T2-e: Config::load on an empty file returns all-default values.
330 #[test]
331 fn test_load_empty_file() {
332 let dir = TempDir::new().unwrap(); // justification: test setup
333 let path = dir.path().join("config.toml");
334 fs::write(&path, "").unwrap(); // justification: writing empty file in test, infallible on tempdir
335
336 let cfg = Config::load(&path).expect("empty file should parse as default");
337 assert!(cfg.recipes.dirs.is_empty());
338 assert!(cfg.paths.global_justfile.is_none());
339 }
340
341 /// T2-f: Config::load on a file with only [paths] section (no [recipes]).
342 #[test]
343 fn test_load_partial_file_no_recipes() {
344 let dir = TempDir::new().unwrap(); // justification: test setup
345 let path = dir.path().join("config.toml");
346 fs::write(&path, "[paths]\nglobal_justfile = \"/etc/lds/justfile\"\n").unwrap(); // justification: writing known-good TOML in test
347
348 let cfg = Config::load(&path).expect("partial file should parse");
349 assert!(
350 cfg.recipes.dirs.is_empty(),
351 "missing [recipes] should default to empty"
352 );
353 assert_eq!(
354 cfg.paths.global_justfile,
355 Some(PathBuf::from("/etc/lds/justfile"))
356 );
357 }
358
359 // ------------------------------------------------------------------
360 // T3: error-path tests
361 // ------------------------------------------------------------------
362
363 /// T3-a: Config::load on a non-existent path returns ConfigError::Io(NotFound).
364 #[test]
365 fn test_load_nonexistent_returns_io_not_found() {
366 let result = Config::load(Path::new("/nonexistent/path/config.toml"));
367 match result {
368 Err(ConfigError::Io(e)) => {
369 assert_eq!(e.kind(), io::ErrorKind::NotFound);
370 }
371 other => panic!("expected Io(NotFound), got {:?}", other),
372 }
373 }
374
375 /// T3-b: Config::load on malformed TOML returns ConfigError::Parse.
376 #[test]
377 fn test_load_malformed_toml_returns_parse_error() {
378 let dir = TempDir::new().unwrap(); // justification: test setup
379 let path = dir.path().join("config.toml");
380 fs::write(&path, "this is not = valid toml [\n").unwrap(); // justification: intentional bad TOML for error path test
381
382 let result = Config::load(&path);
383 assert!(
384 matches!(result, Err(ConfigError::Parse(_))),
385 "malformed TOML should yield Parse error, got {:?}",
386 result
387 );
388 }
389
390 // ------------------------------------------------------------------
391 // Crux 2 preservation test: patch-safe write
392 // ------------------------------------------------------------------
393
394 /// Crux 2: `Config::save` must preserve comments and unrelated sections.
395 ///
396 /// This test writes a config.toml with a comment and `[paths]` section,
397 /// then calls `Config::save` to update `recipes.dirs`, and asserts that
398 /// the comment and `[paths]` section survive unmodified.
399 #[test]
400 fn test_save_preserves_comments_and_other_sections() {
401 let dir = TempDir::new().unwrap(); // justification: test setup
402 let path = dir.path().join("config.toml");
403
404 // Seed file with a comment and [paths] section.
405 let initial = r#"# This is a user comment that must survive.
406[recipes]
407dirs = []
408
409[paths]
410global_justfile = "/etc/lds/justfile"
411"#;
412 fs::write(&path, initial).unwrap(); // justification: seeding known-good TOML in test
413
414 let new_dirs = vec![PathBuf::from("/opt/recipes")];
415 Config::save(&path, &new_dirs).expect("save should succeed");
416
417 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
418
419 // Comment must be preserved.
420 assert!(
421 saved.contains("# This is a user comment that must survive."),
422 "comment was not preserved:\n{}",
423 saved
424 );
425
426 // [paths] section must be preserved.
427 assert!(
428 saved.contains("[paths]"),
429 "[paths] section was not preserved:\n{}",
430 saved
431 );
432 assert!(
433 saved.contains("global_justfile"),
434 "global_justfile key was not preserved:\n{}",
435 saved
436 );
437
438 // recipes.dirs must be updated.
439 let cfg = Config::load(&path).expect("load after save should succeed");
440 assert_eq!(cfg.recipes.dirs, new_dirs);
441
442 // Crux 2: tilde literal must not appear on disk.
443 assert!(
444 !saved.contains('~'),
445 "tilde literal found on disk — crux 2 violation:\n{}",
446 saved
447 );
448 }
449
450 /// Crux 2 (tilde): paths saved to disk must be absolute (no tilde literal).
451 #[test]
452 fn test_save_does_not_write_tilde_literal() {
453 if dirs::home_dir().is_none() {
454 return;
455 }
456 let dir = TempDir::new().unwrap(); // justification: test setup
457 let path = dir.path().join("config.toml");
458
459 // Expand tilde before saving — as callers are required to do.
460 let raw = "~/my-recipes";
461 let expanded = tilde_expand(raw).expect("tilde_expand should succeed");
462 assert!(
463 !expanded.to_string_lossy().contains('~'),
464 "expanded path must not contain tilde"
465 );
466
467 Config::save(&path, &[expanded]).expect("save should succeed");
468
469 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
470 assert!(
471 !saved.contains('~'),
472 "tilde literal found on disk after save — crux 2 violation:\n{}",
473 saved
474 );
475 }
476}