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 /// `lds pack` classification overrides.
68 pub pack: Pack,
69}
70
71/// Recipe-related configuration.
72#[derive(Debug, Clone, Default, Deserialize, Serialize)]
73#[serde(default)]
74pub struct Recipes {
75 /// Additional global recipe directories (highest priority source).
76 ///
77 /// Entries are absolute paths. Tilde is expanded on load and must be
78 /// absent from `config.toml` on disk.
79 pub dirs: Vec<PathBuf>,
80}
81
82/// Classification overrides for `lds pack`.
83///
84/// Every list here is **added to** the built-in defaults rather than replacing
85/// them, so a project that declares one project-specific secret name does not
86/// silently lose the protection of the built-in list. `keep` is the one escape
87/// hatch that subtracts: it names files the built-ins would classify as secret
88/// or cache but that this operator wants carried anyway.
89///
90/// Every list here scopes its globs the way `.gitignore` does: one with no `/`
91/// (`*.vault`, `my-app-keys.json`) matches the **file name** at any depth, one
92/// with a `/` (`docs/samples/*.pem`, `frontend/dist`) is anchored to that
93/// **path relative to the project root**.
94///
95/// Reaching the whole tree is the right default for naming a kind of file, and
96/// the hazard when naming one particular file: `keep = ["*.pem"]` written to
97/// carry one sample key carries every private key in the project. Anchor such a
98/// rule to a path and it stays where it was meant to apply.
99#[derive(Debug, Clone, Default, Deserialize, Serialize)]
100#[serde(default)]
101pub struct Pack {
102 /// Extra globs to treat as secrets (never packed, only reported).
103 pub secret_globs: Vec<String>,
104 /// Extra directories to treat as regenerable caches (never packed).
105 pub cache_dirs: Vec<String>,
106 /// Globs that must be packed even if a built-in rule excludes them.
107 ///
108 /// The only subtractive list, and so the only way a file the secret rules
109 /// named ends up in the archive. Anything it rescues is recorded in the
110 /// manifest's `kept_over_secret`.
111 pub keep: Vec<String>,
112 /// Path globs whose symlinks are packed but left out of the link report.
113 ///
114 /// A symlink is a problem by default: it breaks when the project is carried
115 /// somewhere else, so every one is reported for the operator to deal with.
116 /// The exception is a directory that is *meant* to be links — a shared
117 /// dotfile tree such as `.zsh/`, deployed the same way on every machine the
118 /// operator uses. Those are already known, so reporting them is noise that
119 /// hides the links that do need attention.
120 ///
121 /// Scoped like the lists above, and in practice always with a `/` — what
122 /// makes links expected is where they sit.
123 ///
124 /// No built-in default: only the operator knows which of their directories
125 /// are link-by-design. Left unset, every symlink is reported.
126 ///
127 /// Suppression affects the report alone — the links are packed either way,
128 /// and every rule that suppressed something is named in the manifest, so a
129 /// silent report can always be told apart from an empty one.
130 pub no_link_report: Vec<String>,
131}
132
133/// Path overrides for well-known lds locations.
134#[derive(Debug, Clone, Default, Deserialize, Serialize)]
135#[serde(default)]
136pub struct Paths {
137 /// Override for the global justfile path (default: `~/.config/lds/justfile`).
138 pub global_justfile: Option<PathBuf>,
139}
140
141// ---------------------------------------------------------------------------
142// tilde_expand
143// ---------------------------------------------------------------------------
144
145/// Expand a leading `~/` or lone `~` to the user's home directory.
146///
147/// # Arguments
148///
149/// * `input` — A path string that may start with `~/`.
150///
151/// # Returns
152///
153/// An absolute `PathBuf`. If `input` does not start with `~/` or `~`, it is
154/// returned as-is wrapped in `PathBuf`.
155///
156/// # Errors
157///
158/// Returns `ConfigError::Io(NotFound)` when the home directory cannot be
159/// determined (e.g. `$HOME` is unset on Unix).
160pub fn tilde_expand(input: &str) -> Result<PathBuf, ConfigError> {
161 if input == "~" {
162 let home = dirs::home_dir().ok_or_else(|| {
163 ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
164 })?;
165 Ok(home)
166 } else if let Some(rest) = input.strip_prefix("~/") {
167 let home = dirs::home_dir().ok_or_else(|| {
168 ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
169 })?;
170 Ok(home.join(rest))
171 } else {
172 Ok(PathBuf::from(input))
173 }
174}
175
176// ---------------------------------------------------------------------------
177// Config impl
178// ---------------------------------------------------------------------------
179
180/// Resolve the well-known path to the user-global config file
181/// (`~/.config/lds/config.toml`).
182///
183/// Returns `None` if the home directory cannot be determined (e.g. `$HOME`
184/// is unset). Shared by [`Config::load_or_default`] and by the `lds` binary
185/// crate, which also points `lds_router::RouteConfig::load_all` at this same
186/// path so `[[route]]` / `[[export]]` declarations live in the one file.
187pub fn user_config_path() -> Option<PathBuf> {
188 dirs::home_dir().map(|home| home.join(".config/lds/config.toml"))
189}
190
191impl Config {
192 /// Load configuration from an explicit file path.
193 ///
194 /// # Arguments
195 ///
196 /// * `path` — Path to a TOML configuration file.
197 ///
198 /// # Returns
199 ///
200 /// A fully populated `Config`. Missing optional sections are filled with
201 /// `Default`.
202 ///
203 /// # Errors
204 ///
205 /// - `ConfigError::Io` if the file cannot be read.
206 /// - `ConfigError::Parse` if the TOML is malformed.
207 pub fn load(path: &Path) -> Result<Self, ConfigError> {
208 let content = std::fs::read_to_string(path)?;
209 let config: Config = toml::from_str(&content)?;
210 Ok(config)
211 }
212
213 /// Load configuration from the default path (`~/.config/lds/config.toml`).
214 ///
215 /// If the file does not exist this returns `Config::default()` silently.
216 /// Any other I/O error or parse error is also silently swallowed and the
217 /// default is returned — suitable for startup where a missing config is
218 /// expected to be common.
219 ///
220 /// # Returns
221 ///
222 /// A `Config`, falling back to `Default` on any error.
223 pub fn load_or_default() -> Self {
224 let Some(path) = user_config_path() else {
225 return Self::default();
226 };
227 match Self::load(&path) {
228 Ok(cfg) => cfg,
229 Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => Self::default(),
230 Err(e) => {
231 tracing::warn!("failed to load config from {}: {}", path.display(), e);
232 Self::default()
233 }
234 }
235 }
236
237 /// Save the `recipes.dirs` list to `path` using a **patch-safe** write.
238 ///
239 /// The file is parsed by `toml_edit` so that comments and sections not
240 /// managed by this function (e.g. `[paths]`) are preserved verbatim.
241 /// Only the `recipes.dirs` array is replaced.
242 ///
243 /// All paths in `dirs` must already be absolute (tilde-expanded before
244 /// calling this function). Passing a tilde literal is a logic error and
245 /// will be written literally — callers are responsible for expanding first.
246 ///
247 /// If the parent directory does not exist it is created with
248 /// `fs::create_dir_all`.
249 ///
250 /// # Arguments
251 ///
252 /// * `path` — Destination file (typically `~/.config/lds/config.toml`).
253 /// * `dirs` — Absolute paths to persist in `recipes.dirs`.
254 ///
255 /// # Errors
256 ///
257 /// - `ConfigError::Io` for I/O failures (create dir, read, write).
258 /// - `ConfigError::Edit` if the existing file is not valid TOML.
259 pub fn save(path: &Path, dirs: &[PathBuf]) -> Result<(), ConfigError> {
260 // Ensure parent directory exists.
261 if let Some(parent) = path.parent() {
262 std::fs::create_dir_all(parent)?;
263 }
264
265 // Read existing content (empty string when file is absent).
266 let existing = match std::fs::read_to_string(path) {
267 Ok(s) => s,
268 Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
269 Err(e) => return Err(ConfigError::Io(e)),
270 };
271
272 // Parse with toml_edit to preserve comments and unrelated sections.
273 let mut doc: DocumentMut = existing.parse::<DocumentMut>()?;
274
275 // Build a fresh TOML array from `dirs`.
276 let mut arr = Array::new();
277 for dir in dirs {
278 // Safety: PathBuf::to_string_lossy is infallible (may be lossy on
279 // non-UTF-8 systems, but that is acceptable given TOML's UTF-8 requirement).
280 arr.push(dir.to_string_lossy().as_ref());
281 }
282
283 // Write `recipes.dirs` — create intermediate tables as needed.
284 if !doc.contains_table("recipes") {
285 doc["recipes"] = toml_edit::table();
286 }
287 doc["recipes"]["dirs"] = Item::Value(Value::Array(arr));
288
289 std::fs::write(path, doc.to_string())?;
290 Ok(())
291 }
292}
293
294// ---------------------------------------------------------------------------
295// Tests
296// ---------------------------------------------------------------------------
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::fs;
302 use tempfile::TempDir;
303
304 // ------------------------------------------------------------------
305 // T1: happy-path / property tests
306 // ------------------------------------------------------------------
307
308 /// T1-a: round-trip — serialize a Config and read it back identically.
309 #[test]
310 fn test_round_trip_load_save() {
311 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
312 let path = dir.path().join("config.toml");
313
314 let other_root = TempDir::new().unwrap();
315 let dirs_in = vec![
316 dir.path().join("shared-recipes"),
317 other_root.path().join("team-recipes"),
318 ];
319
320 Config::save(&path, &dirs_in).expect("save should succeed");
321 let cfg = Config::load(&path).expect("load should succeed");
322
323 assert_eq!(cfg.recipes.dirs, dirs_in);
324 }
325
326 /// T1-b: load_or_default returns Default when no file exists.
327 #[test]
328 fn test_load_or_default_missing_file() {
329 // Temporarily override HOME to a directory with no config.toml.
330 let dir = TempDir::new().unwrap(); // justification: same as above
331 // We cannot easily unset HOME in a portable way, so we test Config::load
332 // directly with a non-existent path to exercise the NotFound branch.
333 let path = dir.path().join("nonexistent/config.toml");
334 match Config::load(&path) {
335 Err(ConfigError::Io(e)) => {
336 assert_eq!(e.kind(), io::ErrorKind::NotFound);
337 }
338 other => panic!("expected Io(NotFound), got {:?}", other),
339 }
340 }
341
342 /// T1-c0: `user_config_path` resolves to `<home>/.config/lds/config.toml`
343 /// when the home directory is available.
344 #[test]
345 fn test_user_config_path_under_home() {
346 let Some(home) = dirs::home_dir() else {
347 return;
348 };
349 let path = user_config_path().expect("home dir is available in this test branch");
350 assert_eq!(path, home.join(".config/lds/config.toml"));
351 }
352
353 /// T1-c: tilde_expand returns an absolute path for a ~/... input.
354 #[test]
355 fn test_tilde_expand_tilde_slash() {
356 // Only run when HOME is available.
357 if dirs::home_dir().is_none() {
358 return;
359 }
360 let result = tilde_expand("~/foo/bar").expect("tilde_expand should succeed");
361 let home = dirs::home_dir().unwrap(); // justification: we just checked it is Some above
362 assert_eq!(result, home.join("foo/bar"));
363 }
364
365 /// T1-d: tilde_expand with bare `~`.
366 #[test]
367 fn test_tilde_expand_bare_tilde() {
368 if dirs::home_dir().is_none() {
369 return;
370 }
371 let result = tilde_expand("~").expect("bare tilde should expand");
372 let home = dirs::home_dir().unwrap(); // justification: checked is Some above
373 assert_eq!(result, home);
374 }
375
376 // ------------------------------------------------------------------
377 // T2: boundary / edge-case tests
378 // ------------------------------------------------------------------
379
380 /// T2-a: empty dirs list produces empty `recipes.dirs` array.
381 #[test]
382 fn test_save_empty_dirs() {
383 let dir = TempDir::new().unwrap(); // justification: test setup
384 let path = dir.path().join("config.toml");
385
386 Config::save(&path, &[]).expect("save should succeed");
387 let cfg = Config::load(&path).expect("load should succeed");
388 assert!(cfg.recipes.dirs.is_empty());
389 }
390
391 /// T2-b: load_or_default on truly missing file via `Config::load` NotFound.
392 #[test]
393 fn test_load_or_default_does_not_panic_on_missing() {
394 // Exercise the public load_or_default by calling it; if HOME is not
395 // set or the file is absent it returns Default without panic.
396 let _cfg = Config::load_or_default();
397 // No assertion needed — absence of panic is the contract.
398 }
399
400 /// T2-c: tilde_expand with no tilde passes through unchanged.
401 #[test]
402 fn test_tilde_expand_no_tilde() {
403 let result = tilde_expand("/absolute/path").expect("should succeed");
404 assert_eq!(result, PathBuf::from("/absolute/path"));
405 }
406
407 /// T2-d: tilde_expand with a relative path (no tilde) passes through.
408 #[test]
409 fn test_tilde_expand_relative() {
410 let result = tilde_expand("relative/path").expect("should succeed");
411 assert_eq!(result, PathBuf::from("relative/path"));
412 }
413
414 /// T2-e: Config::load on an empty file returns all-default values.
415 #[test]
416 fn test_load_empty_file() {
417 let dir = TempDir::new().unwrap(); // justification: test setup
418 let path = dir.path().join("config.toml");
419 fs::write(&path, "").unwrap(); // justification: writing empty file in test, infallible on tempdir
420
421 let cfg = Config::load(&path).expect("empty file should parse as default");
422 assert!(cfg.recipes.dirs.is_empty());
423 assert!(cfg.paths.global_justfile.is_none());
424 }
425
426 /// T2-f: Config::load on a file with only [paths] section (no [recipes]).
427 #[test]
428 fn test_load_partial_file_no_recipes() {
429 let dir = TempDir::new().unwrap(); // justification: test setup
430 let path = dir.path().join("config.toml");
431 fs::write(&path, "[paths]\nglobal_justfile = \"/etc/lds/justfile\"\n").unwrap(); // justification: writing known-good TOML in test
432
433 let cfg = Config::load(&path).expect("partial file should parse");
434 assert!(
435 cfg.recipes.dirs.is_empty(),
436 "missing [recipes] should default to empty"
437 );
438 assert_eq!(
439 cfg.paths.global_justfile,
440 Some(PathBuf::from("/etc/lds/justfile"))
441 );
442 }
443
444 /// T2-g: `Config::load` ignores `[[route]]` / `[[export]]` sections.
445 ///
446 /// `lds-router` parses these same array-of-tables out of the same
447 /// physical `config.toml` (see the module doc comment's "shared file,
448 /// decoupled schemas" note); `Config` has no `route`/`export` field, so
449 /// this exercises serde's "unrecognized top-level keys are ignored"
450 /// default behavior rather than a hard failure — this is the sole
451 /// mechanism that lets the two crates share one file without either
452 /// depending on the other's types.
453 #[test]
454 fn test_load_ignores_route_and_export_sections() {
455 let dir = TempDir::new().unwrap(); // justification: test setup
456 let path = dir.path().join("config.toml");
457 fs::write(
458 &path,
459 r#"
460[recipes]
461dirs = ["/opt/shared-recipes"]
462
463[[route]]
464name = "outline"
465command = "outline-mcp"
466
467[[export]]
468route = "outline"
469tools = ["snapshot_create"]
470"#,
471 )
472 .unwrap(); // justification: writing known-good TOML in test
473
474 let cfg = Config::load(&path).expect("route/export sections must not fail Config parsing");
475 assert_eq!(cfg.recipes.dirs, vec![PathBuf::from("/opt/shared-recipes")]);
476 }
477
478 // ------------------------------------------------------------------
479 // T3: error-path tests
480 // ------------------------------------------------------------------
481
482 /// T3-a: Config::load on a non-existent path returns ConfigError::Io(NotFound).
483 #[test]
484 fn test_load_nonexistent_returns_io_not_found() {
485 let result = Config::load(Path::new("/nonexistent/path/config.toml"));
486 match result {
487 Err(ConfigError::Io(e)) => {
488 assert_eq!(e.kind(), io::ErrorKind::NotFound);
489 }
490 other => panic!("expected Io(NotFound), got {:?}", other),
491 }
492 }
493
494 /// T3-b: Config::load on malformed TOML returns ConfigError::Parse.
495 #[test]
496 fn test_load_malformed_toml_returns_parse_error() {
497 let dir = TempDir::new().unwrap(); // justification: test setup
498 let path = dir.path().join("config.toml");
499 fs::write(&path, "this is not = valid toml [\n").unwrap(); // justification: intentional bad TOML for error path test
500
501 let result = Config::load(&path);
502 assert!(
503 matches!(result, Err(ConfigError::Parse(_))),
504 "malformed TOML should yield Parse error, got {:?}",
505 result
506 );
507 }
508
509 // ------------------------------------------------------------------
510 // Crux 2 preservation test: patch-safe write
511 // ------------------------------------------------------------------
512
513 /// Crux 2: `Config::save` must preserve comments and unrelated sections.
514 ///
515 /// This test writes a config.toml with a comment and `[paths]` section,
516 /// then calls `Config::save` to update `recipes.dirs`, and asserts that
517 /// the comment and `[paths]` section survive unmodified.
518 #[test]
519 fn test_save_preserves_comments_and_other_sections() {
520 let dir = TempDir::new().unwrap(); // justification: test setup
521 let path = dir.path().join("config.toml");
522
523 // Seed file with a comment and [paths] section.
524 let initial = r#"# This is a user comment that must survive.
525[recipes]
526dirs = []
527
528[paths]
529global_justfile = "/etc/lds/justfile"
530"#;
531 fs::write(&path, initial).unwrap(); // justification: seeding known-good TOML in test
532
533 let new_dirs = vec![PathBuf::from("/opt/recipes")];
534 Config::save(&path, &new_dirs).expect("save should succeed");
535
536 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
537
538 // Comment must be preserved.
539 assert!(
540 saved.contains("# This is a user comment that must survive."),
541 "comment was not preserved:\n{}",
542 saved
543 );
544
545 // [paths] section must be preserved.
546 assert!(
547 saved.contains("[paths]"),
548 "[paths] section was not preserved:\n{}",
549 saved
550 );
551 assert!(
552 saved.contains("global_justfile"),
553 "global_justfile key was not preserved:\n{}",
554 saved
555 );
556
557 // recipes.dirs must be updated.
558 let cfg = Config::load(&path).expect("load after save should succeed");
559 assert_eq!(cfg.recipes.dirs, new_dirs);
560
561 // Crux 2: tilde literal must not appear on disk.
562 assert!(
563 !saved.contains('~'),
564 "tilde literal found on disk — crux 2 violation:\n{}",
565 saved
566 );
567 }
568
569 /// Crux 2 (tilde): paths saved to disk must be absolute (no tilde literal).
570 #[test]
571 fn test_save_does_not_write_tilde_literal() {
572 if dirs::home_dir().is_none() {
573 return;
574 }
575 let dir = TempDir::new().unwrap(); // justification: test setup
576 let path = dir.path().join("config.toml");
577
578 // Expand tilde before saving — as callers are required to do.
579 let raw = "~/my-recipes";
580 let expanded = tilde_expand(raw).expect("tilde_expand should succeed");
581 assert!(
582 !expanded.to_string_lossy().contains('~'),
583 "expanded path must not contain tilde"
584 );
585
586 Config::save(&path, &[expanded]).expect("save should succeed");
587
588 let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
589 assert!(
590 !saved.contains('~'),
591 "tilde literal found on disk after save — crux 2 violation:\n{}",
592 saved
593 );
594 }
595}