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 other_root = TempDir::new().unwrap();
262 let dirs_in = vec![
263 dir.path().join("shared-recipes"),
264 other_root.path().join("team-recipes"),
265 ];
266
267 Config::save(&path, &dirs_in).expect("save should succeed");
268 let cfg = Config::load(&path).expect("load should succeed");
269
270 assert_eq!(cfg.recipes.dirs, dirs_in);
271 }
272
273 /// T1-b: load_or_default returns Default when no file exists.
274 #[test]
275 fn test_load_or_default_missing_file() {
276 // Temporarily override HOME to a directory with no config.toml.
277 let dir = TempDir::new().unwrap(); // justification: same as above
278 // We cannot easily unset HOME in a portable way, so we test Config::load
279 // directly with a non-existent path to exercise the NotFound branch.
280 let path = dir.path().join("nonexistent/config.toml");
281 match Config::load(&path) {
282 Err(ConfigError::Io(e)) => {
283 assert_eq!(e.kind(), io::ErrorKind::NotFound);
284 }
285 other => panic!("expected Io(NotFound), got {:?}", other),
286 }
287 }
288
289 /// T1-c0: `user_config_path` resolves to `<home>/.config/lds/config.toml`
290 /// when the home directory is available.
291 #[test]
292 fn test_user_config_path_under_home() {
293 let Some(home) = dirs::home_dir() else {
294 return;
295 };
296 let path = user_config_path().expect("home dir is available in this test branch");
297 assert_eq!(path, home.join(".config/lds/config.toml"));
298 }
299
300 /// T1-c: tilde_expand returns an absolute path for a ~/... input.
301 #[test]
302 fn test_tilde_expand_tilde_slash() {
303 // Only run when HOME is available.
304 if dirs::home_dir().is_none() {
305 return;
306 }
307 let result = tilde_expand("~/foo/bar").expect("tilde_expand should succeed");
308 let home = dirs::home_dir().unwrap(); // justification: we just checked it is Some above
309 assert_eq!(result, home.join("foo/bar"));
310 }
311
312 /// T1-d: tilde_expand with bare `~`.
313 #[test]
314 fn test_tilde_expand_bare_tilde() {
315 if dirs::home_dir().is_none() {
316 return;
317 }
318 let result = tilde_expand("~").expect("bare tilde should expand");
319 let home = dirs::home_dir().unwrap(); // justification: checked is Some above
320 assert_eq!(result, home);
321 }
322
323 // ------------------------------------------------------------------
324 // T2: boundary / edge-case tests
325 // ------------------------------------------------------------------
326
327 /// T2-a: empty dirs list produces empty `recipes.dirs` array.
328 #[test]
329 fn test_save_empty_dirs() {
330 let dir = TempDir::new().unwrap(); // justification: test setup
331 let path = dir.path().join("config.toml");
332
333 Config::save(&path, &[]).expect("save should succeed");
334 let cfg = Config::load(&path).expect("load should succeed");
335 assert!(cfg.recipes.dirs.is_empty());
336 }
337
338 /// T2-b: load_or_default on truly missing file via `Config::load` NotFound.
339 #[test]
340 fn test_load_or_default_does_not_panic_on_missing() {
341 // Exercise the public load_or_default by calling it; if HOME is not
342 // set or the file is absent it returns Default without panic.
343 let _cfg = Config::load_or_default();
344 // No assertion needed — absence of panic is the contract.
345 }
346
347 /// T2-c: tilde_expand with no tilde passes through unchanged.
348 #[test]
349 fn test_tilde_expand_no_tilde() {
350 let result = tilde_expand("/absolute/path").expect("should succeed");
351 assert_eq!(result, PathBuf::from("/absolute/path"));
352 }
353
354 /// T2-d: tilde_expand with a relative path (no tilde) passes through.
355 #[test]
356 fn test_tilde_expand_relative() {
357 let result = tilde_expand("relative/path").expect("should succeed");
358 assert_eq!(result, PathBuf::from("relative/path"));
359 }
360
361 /// T2-e: Config::load on an empty file returns all-default values.
362 #[test]
363 fn test_load_empty_file() {
364 let dir = TempDir::new().unwrap(); // justification: test setup
365 let path = dir.path().join("config.toml");
366 fs::write(&path, "").unwrap(); // justification: writing empty file in test, infallible on tempdir
367
368 let cfg = Config::load(&path).expect("empty file should parse as default");
369 assert!(cfg.recipes.dirs.is_empty());
370 assert!(cfg.paths.global_justfile.is_none());
371 }
372
373 /// T2-f: Config::load on a file with only [paths] section (no [recipes]).
374 #[test]
375 fn test_load_partial_file_no_recipes() {
376 let dir = TempDir::new().unwrap(); // justification: test setup
377 let path = dir.path().join("config.toml");
378 fs::write(&path, "[paths]\nglobal_justfile = \"/etc/lds/justfile\"\n").unwrap(); // justification: writing known-good TOML in test
379
380 let cfg = Config::load(&path).expect("partial file should parse");
381 assert!(
382 cfg.recipes.dirs.is_empty(),
383 "missing [recipes] should default to empty"
384 );
385 assert_eq!(
386 cfg.paths.global_justfile,
387 Some(PathBuf::from("/etc/lds/justfile"))
388 );
389 }
390
391 /// T2-g: `Config::load` ignores `[[route]]` / `[[export]]` sections.
392 ///
393 /// `lds-router` parses these same array-of-tables out of the same
394 /// physical `config.toml` (see the module doc comment's "shared file,
395 /// decoupled schemas" note); `Config` has no `route`/`export` field, so
396 /// this exercises serde's "unrecognized top-level keys are ignored"
397 /// default behavior rather than a hard failure — this is the sole
398 /// mechanism that lets the two crates share one file without either
399 /// depending on the other's types.
400 #[test]
401 fn test_load_ignores_route_and_export_sections() {
402 let dir = TempDir::new().unwrap(); // justification: test setup
403 let path = dir.path().join("config.toml");
404 fs::write(
405 &path,
406 r#"
407[recipes]
408dirs = ["/opt/shared-recipes"]
409
410[[route]]
411name = "outline"
412command = "outline-mcp"
413
414[[export]]
415route = "outline"
416tools = ["snapshot_create"]
417"#,
418 )
419 .unwrap(); // justification: writing known-good TOML in test
420
421 let cfg = Config::load(&path).expect("route/export sections must not fail Config parsing");
422 assert_eq!(cfg.recipes.dirs, vec![PathBuf::from("/opt/shared-recipes")]);
423 }
424
425 // ------------------------------------------------------------------
426 // T3: error-path tests
427 // ------------------------------------------------------------------
428
429 /// T3-a: Config::load on a non-existent path returns ConfigError::Io(NotFound).
430 #[test]
431 fn test_load_nonexistent_returns_io_not_found() {
432 let result = Config::load(Path::new("/nonexistent/path/config.toml"));
433 match result {
434 Err(ConfigError::Io(e)) => {
435 assert_eq!(e.kind(), io::ErrorKind::NotFound);
436 }
437 other => panic!("expected Io(NotFound), got {:?}", other),
438 }
439 }
440
441 /// T3-b: Config::load on malformed TOML returns ConfigError::Parse.
442 #[test]
443 fn test_load_malformed_toml_returns_parse_error() {
444 let dir = TempDir::new().unwrap(); // justification: test setup
445 let path = dir.path().join("config.toml");
446 fs::write(&path, "this is not = valid toml [\n").unwrap(); // justification: intentional bad TOML for error path test
447
448 let result = Config::load(&path);
449 assert!(
450 matches!(result, Err(ConfigError::Parse(_))),
451 "malformed TOML should yield Parse error, got {:?}",
452 result
453 );
454 }
455
456 // ------------------------------------------------------------------
457 // Crux 2 preservation test: patch-safe write
458 // ------------------------------------------------------------------
459
460 /// Crux 2: `Config::save` must preserve comments and unrelated sections.
461 ///
462 /// This test writes a config.toml with a comment and `[paths]` section,
463 /// then calls `Config::save` to update `recipes.dirs`, and asserts that
464 /// the comment and `[paths]` section survive unmodified.
465 #[test]
466 fn test_save_preserves_comments_and_other_sections() {
467 let dir = TempDir::new().unwrap(); // justification: test setup
468 let path = dir.path().join("config.toml");
469
470 // Seed file with a comment and [paths] section.
471 let initial = r#"# This is a user comment that must survive.
472[recipes]
473dirs = []
474
475[paths]
476global_justfile = "/etc/lds/justfile"
477"#;
478 fs::write(&path, initial).unwrap(); // justification: seeding known-good TOML in test
479
480 let new_dirs = vec![PathBuf::from("/opt/recipes")];
481 Config::save(&path, &new_dirs).expect("save should succeed");
482
483 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
484
485 // Comment must be preserved.
486 assert!(
487 saved.contains("# This is a user comment that must survive."),
488 "comment was not preserved:\n{}",
489 saved
490 );
491
492 // [paths] section must be preserved.
493 assert!(
494 saved.contains("[paths]"),
495 "[paths] section was not preserved:\n{}",
496 saved
497 );
498 assert!(
499 saved.contains("global_justfile"),
500 "global_justfile key was not preserved:\n{}",
501 saved
502 );
503
504 // recipes.dirs must be updated.
505 let cfg = Config::load(&path).expect("load after save should succeed");
506 assert_eq!(cfg.recipes.dirs, new_dirs);
507
508 // Crux 2: tilde literal must not appear on disk.
509 assert!(
510 !saved.contains('~'),
511 "tilde literal found on disk — crux 2 violation:\n{}",
512 saved
513 );
514 }
515
516 /// Crux 2 (tilde): paths saved to disk must be absolute (no tilde literal).
517 #[test]
518 fn test_save_does_not_write_tilde_literal() {
519 if dirs::home_dir().is_none() {
520 return;
521 }
522 let dir = TempDir::new().unwrap(); // justification: test setup
523 let path = dir.path().join("config.toml");
524
525 // Expand tilde before saving — as callers are required to do.
526 let raw = "~/my-recipes";
527 let expanded = tilde_expand(raw).expect("tilde_expand should succeed");
528 assert!(
529 !expanded.to_string_lossy().contains('~'),
530 "expanded path must not contain tilde"
531 );
532
533 Config::save(&path, &[expanded]).expect("save should succeed");
534
535 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
536 assert!(
537 !saved.contains('~'),
538 "tilde literal found on disk after save — crux 2 violation:\n{}",
539 saved
540 );
541 }
542}