Skip to main content

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