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