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}
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}