Skip to main content

lattice_config/
loader.rs

1//! Static config-file loader (DESIGN.md §5.12; first slice of the
2//! TOML surface called out in the design doc's "TOML covers static
3//! option overrides only" line).
4//!
5//! Reads `lattice.toml` from the XDG config home
6//! (`~/.config/lattice/lattice.toml` on all Unix incl. macOS, honouring
7//! `$XDG_CONFIG_HOME`; see [`config_home`]) and `.lattice/config.toml`
8//! from the workspace root, in that precedence order. Project beats
9//! user; `:set` writes after startup beat both.
10//!
11//! ## Design (read-only, walk-and-set)
12//!
13//! The loader walks the parsed TOML table breadth-first. For each
14//! sub-table, it asks: does its dotted path match a registered
15//! *structural prefix* (e.g. `completion.per-language`)? If so, the
16//! table is recorded verbatim in [`LoadOutcome::structural`] and
17//! descent stops there -- the caller (App, plugin host) owns the
18//! interpretation of structural sections. If not, descent continues
19//! and any scalar leaf becomes a `parse_and_set_command("key=value")`
20//! call against the supplied [`ConfigRegistry`].
21//!
22//! This keeps the loader policy-free for everything map-shaped:
23//! plugins (Phase 7), per-language overrides (Phase 4.2.g.5 (3b)),
24//! and any future structurally-typed config all flow through the
25//! same `structural` bucket.
26//!
27//! ## Errors are warnings
28//!
29//! Parse failures, unknown keys, and validation rejects all
30//! produce [`LoadMessage`]s the caller surfaces in its message
31//! buffer. Nothing here panics or aborts startup -- a bad config
32//! file is recoverable with an editor and a re-launch.
33//!
34//! ## What this is NOT
35//!
36//! - Hot reload. The loader runs once at startup; `:reload-config`
37//!   is a future addition (will share the walk-and-set core).
38//! - A writer. `:customize` post-1.0 is the round-trip surface;
39//!   it'll move to `toml_edit` for write-preserving edits.
40//! - A keymap loader. Keymaps are a separate registry; their
41//!   loader will compose with this one.
42
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use crate::registry::{ConfigError, ConfigRegistry};
47// TC.2: a composite-schema option takes its TOML value whole, as a tree.
48use crate::schema::{dot_path, toml_to_config_value};
49
50/// Outcome of loading one or more TOML files. The caller drains
51/// `messages` into its echo / message buffer and walks `structural`
52/// to dispatch sub-tables to their owners.
53#[derive(Debug, Default)]
54pub struct LoadOutcome {
55    /// Every diagnostic produced, in load order (files in precedence
56    /// order, keys in walk order). Empty on a clean load.
57    pub messages: Vec<LoadMessage>,
58    /// Tables whose path matched a structural prefix. Keyed by the
59    /// full dotted path (e.g. `"completion.per-language.markdown"`);
60    /// value is the sub-table verbatim. `BTreeMap` so iteration
61    /// order is stable -- tests depend on this.
62    pub structural: BTreeMap<String, toml::Table>,
63    /// Merged TOML tree of every loaded file -- preserves all
64    /// keys (registered + structural + unknown) verbatim. The
65    /// `extend` method deep-merges with later files overriding
66    /// earlier ones at scalar leaves; nested tables merge
67    /// recursively rather than being clobbered. Consumers like
68    /// `workspace/configuration` (Phase 4.1 follow-up) walk this
69    /// by dotted-path to surface server-namespaced config the
70    /// typed registry doesn't know about.
71    pub raw_tree: toml::Table,
72}
73
74impl LoadOutcome {
75    /// Append another outcome's messages and structural sections
76    /// onto this one, preserving order, AND deep-merge the
77    /// raw-tree (later overrides earlier at scalar level; nested
78    /// tables merge recursively).
79    pub fn extend(&mut self, other: LoadOutcome) {
80        self.messages.extend(other.messages);
81        for (k, v) in other.structural {
82            self.structural.insert(k, v);
83        }
84        deep_merge_table(&mut self.raw_tree, other.raw_tree);
85    }
86}
87
88/// Deep-merge `incoming` into `base`. For each key:
89/// - Both sides are tables -> recurse.
90/// - Else -> incoming wins (later loaded file overrides).
91///
92/// Used by `LoadOutcome::extend` so a project config's
93/// `[lsp.rust-analyzer.cargo]` doesn't clobber the user's
94/// `[lsp.rust-analyzer.checkOnSave]` -- both survive in the
95/// merged tree.
96pub(crate) fn deep_merge_table(base: &mut toml::Table, incoming: toml::Table) {
97    for (key, incoming_val) in incoming {
98        match (base.get_mut(&key), incoming_val) {
99            (Some(toml::Value::Table(base_sub)), toml::Value::Table(in_sub)) => {
100                deep_merge_table(base_sub, in_sub);
101            }
102            (_, val) => {
103                base.insert(key, val);
104            }
105        }
106    }
107}
108
109/// One diagnostic from the loader. Caller decides how to surface
110/// it (echo, message buffer, log line); the loader stays
111/// IO-agnostic.
112#[derive(Debug, Clone)]
113pub struct LoadMessage {
114    /// Whether the whole file was lost ([`LoadMessageLevel::Error`]) or
115    /// one key was rejected ([`LoadMessageLevel::Warning`]).
116    pub level: LoadMessageLevel,
117    /// The file the diagnostic came from.
118    pub source: PathBuf,
119    /// Human-readable reason, without the path prefix — the caller
120    /// formats `source` and `body` together.
121    pub body: String,
122    /// OC.11c: the dotted option name this diagnostic is ABOUT, when it is
123    /// about one.
124    ///
125    /// The name was always available at every site that builds one of these —
126    /// it is `dotted`, and it was being formatted into [`body`](Self::body)
127    /// and nowhere else. Keeping it structurally is what lets a consumer ask
128    /// "did MY option fail to load" without matching a substring against a
129    /// message written for a human.
130    ///
131    /// `None` for a diagnostic that is not about a single option: a file that
132    /// could not be read, or whose TOML did not parse. **Those are not
133    /// per-option failures and must not be attributed to one** — a syntax
134    /// error loses the WHOLE file, every option in it, and reporting it
135    /// against whichever option happened to be nearby would be worse than
136    /// saying nothing.
137    pub option: std::option::Option<String>,
138}
139
140/// Severity of a [`LoadMessage`].
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum LoadMessageLevel {
143    /// Couldn't read or parse the file; nothing was applied.
144    Error,
145    /// File loaded but a key was rejected (unknown / invalid /
146    /// validator failure). Other keys still applied.
147    Warning,
148}
149
150/// Walk a TOML table by a dotted path, returning the value at
151/// the leaf or `None` if any segment is missing or steps through
152/// a non-table. Used by `workspace/configuration` to look up
153/// server-namespaced keys (e.g. `"rust-analyzer.cargo.features"`
154/// walks `tree["rust-analyzer"]["cargo"]["features"]`).
155///
156/// # Examples
157///
158/// ```
159/// use lattice_config::lookup_dotted_path;
160///
161/// let tree: toml::Table = "[rust-analyzer.cargo]\nfeatures = \"all\"".parse().unwrap();
162/// let v = lookup_dotted_path(&tree, "rust-analyzer.cargo.features");
163/// assert_eq!(v.and_then(|v| v.as_str()), Some("all"));
164/// // Stepping through a non-table, or a missing segment, is `None`.
165/// assert!(lookup_dotted_path(&tree, "rust-analyzer.cargo.features.x").is_none());
166/// assert!(lookup_dotted_path(&tree, "rust-analyzer.check").is_none());
167/// ```
168pub fn lookup_dotted_path<'a>(tree: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
169    let mut node: &toml::Table = tree;
170    let segments: Vec<&str> = path.split('.').collect();
171    let last_idx = segments.len().checked_sub(1)?;
172    for (i, seg) in segments.iter().enumerate() {
173        let value = node.get(*seg)?;
174        if i == last_idx {
175            return Some(value);
176        }
177        node = value.as_table()?;
178    }
179    None
180}
181
182/// The XDG config base directory for lattice's config root.
183///
184/// Resolves the XDG Base Directory config home so lattice's config lives
185/// under `~/.config/lattice/` on **every** Unix — macOS included — instead of
186/// the platform-native `~/Library/Application Support` that `dirs::config_dir`
187/// returns there. This matches the convention every developer CLI / editor
188/// uses (Helix, Neovim, Zed's CLI, alacritty, starship, …): on macOS they read
189/// `~/.config/<app>`, not `~/Library/Application Support/<app>`.
190///
191/// Precedence (per the XDG Base Directory spec):
192/// 1. `$XDG_CONFIG_HOME` when set to an **absolute** path — honoured on every
193///    platform so an explicit override always wins (a set-but-relative or
194///    empty value is spec-invalid and ignored).
195/// 2. `~/.config` on Unix (via `$HOME`).
196/// 3. `dirs::config_dir()` elsewhere (Windows → `%APPDATA%`).
197///
198/// `None` only when neither the override nor the home directory resolves.
199pub fn config_home() -> Option<PathBuf> {
200    let xdg = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
201    #[cfg(unix)]
202    let fallback = dirs::home_dir().map(|h| h.join(".config"));
203    #[cfg(not(unix))]
204    let fallback = dirs::config_dir();
205    resolve_config_home(xdg, fallback)
206}
207
208/// The cache root, `<config-home>/lattice/cache/`.
209///
210/// Lattice keeps every path it owns under one root (see
211/// `docs/dev/architecture/plugin-data-location.md`), caches included — they
212/// used to sit in `dirs::cache_dir()`, the last paths outside it. The `cache`
213/// subdirectory names the one directory that is always safe to delete: every
214/// entry is regenerable (wasmtime's compiled modules, plugin source
215/// checkouts, the picker's MRU index).
216///
217/// `None` when no config home resolves, which callers read as "persistence
218/// disabled" rather than an error.
219pub fn cache_home() -> Option<PathBuf> {
220    cache_home_from(config_home().as_deref())
221}
222
223/// Pure resolver behind [`cache_home`].
224pub fn cache_home_from(config_home: Option<&Path>) -> Option<PathBuf> {
225    config_home.map(|d| d.join("lattice").join("cache"))
226}
227
228/// Move `old` to `new` if `old` exists and `new` does not, returning whether
229/// anything moved. Works for a file or a directory.
230///
231/// The one-time carry behind every path this project has relocated. A
232/// destination that already exists wins and the old copy is left alone:
233/// whatever a newer lattice wrote is the live state. A failure is reported as
234/// `false` rather than an error — a cache that cannot be moved is rebuilt,
235/// and nothing here is worth failing a boot over.
236pub fn migrate_path(old: &Path, new: &Path) -> bool {
237    if old == new || !old.exists() || new.exists() {
238        return false;
239    }
240    if let Some(parent) = new.parent()
241        && std::fs::create_dir_all(parent).is_err()
242    {
243        return false;
244    }
245    // `rename` failing (a cross-device move, a permission error) is not worth
246    // a message from a crate with no logger: the caller carries on with the
247    // old path absent, and a cache rebuilds itself.
248    std::fs::rename(old, new).is_ok()
249}
250
251/// Pure resolver behind [`config_home`], split out so the precedence logic is
252/// unit-testable without mutating process-global environment variables.
253fn resolve_config_home(
254    xdg_config_home: Option<PathBuf>,
255    fallback: Option<PathBuf>,
256) -> Option<PathBuf> {
257    if let Some(xdg) = xdg_config_home
258        && xdg.is_absolute()
259    {
260        return Some(xdg);
261    }
262    fallback
263}
264
265/// Default user config path: `<config_home>/lattice/lattice.toml`, i.e.
266/// `~/.config/lattice/lattice.toml` (honouring `$XDG_CONFIG_HOME`). See
267/// [`config_home`] for the cross-platform resolution. `None` when no config
268/// home resolves (no `$XDG_CONFIG_HOME` override and no `$HOME`).
269pub fn default_user_config_path() -> Option<PathBuf> {
270    config_home().map(|d| d.join("lattice").join("lattice.toml"))
271}
272
273/// Default project config path: `<workspace_root>/.lattice/config.toml`.
274/// Caller supplies `workspace_root` (typically the directory the
275/// editor was launched in, walked up to the first `.git` /
276/// `.lattice/` marker if desired).
277pub fn project_config_path(workspace_root: &Path) -> PathBuf {
278    workspace_root.join(".lattice").join("config.toml")
279}
280
281/// Load both default paths in standard precedence order: user
282/// first, then project. Missing files are silent (no message).
283/// `structural_prefixes` lists dotted prefixes the caller wants
284/// to handle itself (e.g. `&["completion.per-language", "plugin"]`).
285pub fn load_default_paths(
286    registry: &ConfigRegistry,
287    workspace_root: Option<&Path>,
288    structural_prefixes: &[&str],
289) -> LoadOutcome {
290    let mut out = LoadOutcome::default();
291    // OC.11c: cleared ONCE per load, not once per file. A load is a fresh
292    // reading of the whole configuration, so the previous reading's failures
293    // go wholesale — an option whose failing line the user DELETED produces no
294    // message at all, and a per-message update would leave its record behind
295    // forever. Clearing inside `load_file` would instead have the PROJECT
296    // config wipe what the user config just recorded.
297    registry.clear_all_failed_assignments();
298    if let Some(user) = default_user_config_path()
299        && user.exists()
300    {
301        out.extend(load_file(registry, &user, structural_prefixes));
302    }
303    if let Some(root) = workspace_root {
304        let proj = project_config_path(root);
305        if proj.exists() {
306            out.extend(load_file(registry, &proj, structural_prefixes));
307        }
308    }
309    // Recorded from the messages rather than at each push site: the loader
310    // stays IO-agnostic and builds `LoadMessage`s in a dozen places, and one
311    // pass over the result is both simpler and impossible to miss an arm of.
312    // Later files win, which is the precedence the loads themselves have.
313    for message in &out.messages {
314        if let Some(option) = &message.option {
315            registry.record_failed_assignment(
316                option,
317                message.body.clone(),
318                Some(message.source.clone()),
319            );
320        }
321    }
322    out
323}
324
325/// Load a single TOML file, applying scalar leaves to `registry`
326/// and bucketing structural sub-tables. The path is recorded in
327/// every emitted message so the caller can surface
328/// `path:reason`-style diagnostics.
329pub fn load_file(
330    registry: &ConfigRegistry,
331    path: &Path,
332    structural_prefixes: &[&str],
333) -> LoadOutcome {
334    let mut out = LoadOutcome::default();
335    let text = match std::fs::read_to_string(path) {
336        Ok(s) => s,
337        Err(e) => {
338            out.messages.push(LoadMessage {
339                level: LoadMessageLevel::Error,
340                source: path.to_path_buf(),
341                body: format!("read failed: {e}"),
342                // The FILE could not be read — not one option's failure.
343                option: None,
344            });
345            return out;
346        }
347    };
348    let table: toml::Table = match text.parse() {
349        Ok(t) => t,
350        Err(e) => {
351            // toml::de::Error already includes line/col in its
352            // Display; pass the body through verbatim.
353            out.messages.push(LoadMessage {
354                level: LoadMessageLevel::Error,
355                source: path.to_path_buf(),
356                body: format!("parse failed: {e}"),
357                // A syntax error loses every option in the file, so this
358                // diagnostic belongs to none of them.
359                option: None,
360            });
361            return out;
362        }
363    };
364    // Stash the parsed tree before walking. Consumers like
365    // `workspace/configuration` walk this by dotted-path; the
366    // walker's apply-and-bucket pass below stays unchanged.
367    out.raw_tree = table.clone();
368    walk_table(registry, path, &mut out, structural_prefixes, &[], &table);
369    out
370}
371
372/// Recursive walker. `prefix` is the dotted path leading to
373/// `table` (empty at the root). For each child:
374///
375/// - **Sub-table whose path EQUALS a structural prefix** -> enter
376///   namespace mode: each direct child sub-table of this node
377///   becomes a structural entry (keyed by its full dotted path);
378///   non-table children warn (a structural namespace can't hold
379///   bare scalars in v1).
380/// - **Sub-table otherwise** -> recurse with extended prefix.
381/// - **Scalar leaf** -> call `parse_and_set_command` on the
382///   registry, capturing any error as a warning.
383///
384/// The "namespace" semantics matches what the per-language
385/// override + plugin-config use cases actually want:
386/// `[completion.per-language.markdown]` is one entry, keyed by
387/// the language id; `[plugin.rust-analyzer]` is one plugin, keyed
388/// by the plugin id. Recording the *parent* (`completion.per-language`)
389/// would force every caller to re-walk one extra level.
390fn walk_table(
391    registry: &ConfigRegistry,
392    source: &Path,
393    out: &mut LoadOutcome,
394    structural_prefixes: &[&str],
395    prefix: &[String],
396    table: &toml::Table,
397) {
398    for (key, value) in table {
399        let mut path: Vec<String> = prefix.to_vec();
400        path.push(key.clone());
401        let dotted = path.join(".");
402        match value {
403            toml::Value::Table(sub) => {
404                // TC.2: a table AT an option's name is that option's VALUE,
405                // not a namespace to walk into. Checked before both branches
406                // below because a composite option is a leaf — descending into
407                // it would apply each of its fields as if it were an option of
408                // its own, which is how `[[org.capture-templates]]` used to
409                // become a pile of `unknown option` warnings.
410                if apply_tree_if_composite(registry, source, out, &dotted, value) {
411                    continue;
412                }
413                if structural_prefixes.iter().any(|p| *p == dotted) {
414                    record_namespace_children(source, out, &dotted, sub);
415                } else {
416                    walk_table(registry, source, out, structural_prefixes, &path, sub);
417                }
418            }
419            scalar => {
420                apply_scalar(registry, source, out, &dotted, scalar);
421            }
422        }
423    }
424}
425
426/// Treat `table` as a namespace -- each direct child becomes a
427/// structural entry keyed by `<namespace_path>.<child>`. Non-
428/// table children warn; structural namespaces in v1 hold sub-
429/// tables only.
430fn record_namespace_children(
431    source: &Path,
432    out: &mut LoadOutcome,
433    namespace_dotted: &str,
434    table: &toml::Table,
435) {
436    for (key, value) in table {
437        let dotted = format!("{namespace_dotted}.{key}");
438        match value {
439            toml::Value::Table(sub) => {
440                out.structural.insert(dotted, sub.clone());
441            }
442            _ => {
443                out.messages.push(LoadMessage {
444                    level: LoadMessageLevel::Warning,
445                    source: source.to_path_buf(),
446                    body: format!(
447                        "`{dotted}`: structural namespace `{namespace_dotted}` \
448                         expected a sub-table; got a scalar",
449                    ),
450                    option: Some(dotted.to_string()),
451                });
452            }
453        }
454    }
455}
456
457/// Apply a scalar leaf via `parse_and_set_command`. Captures
458/// every failure mode (unknown option, validation reject, parse
459/// error) as a warning -- the loader never aborts on a single
460/// bad key.
461fn apply_scalar(
462    registry: &ConfigRegistry,
463    source: &Path,
464    out: &mut LoadOutcome,
465    dotted: &str,
466    value: &toml::Value,
467) {
468    // ML.5: a TOML **array** for a list-typed option (e.g.
469    // `ui.modeline.left = ["core.mode", "core.path"]`) is joined into
470    // the delimited form the option's `parse` accepts. For a scalar
471    // option (or an unknown key) an array stays the "not applicable"
472    // warning it was before — never a panic.
473    // TC.2: an array-of-tables (`[[org.capture-templates]]`) reaches here as an
474    // Array at the option's name. A composite option takes it whole; ML.5's
475    // join-into-a-delimited-string is for the list-of-scalars options that
476    // predate schemas and still spell their value as text.
477    if apply_tree_if_composite(registry, source, out, dotted, value) {
478        return;
479    }
480    if let toml::Value::Array(items) = value {
481        apply_array(registry, source, out, dotted, items);
482        return;
483    }
484    let formatted = match format_scalar(value) {
485        Some(s) => s,
486        None => {
487            // Inline tables at scalar position aren't a known option-
488            // value shape; warn so the user notices their config
489            // didn't apply.
490            out.messages.push(LoadMessage {
491                level: LoadMessageLevel::Warning,
492                source: source.to_path_buf(),
493                body: format!(
494                    "`{dotted}`: list / inline-table values aren't \
495                     applicable to scalar options; move it under a \
496                     structural section",
497                ),
498                option: Some(dotted.to_string()),
499            });
500            return;
501        }
502    };
503    apply_assignment(registry, source, out, dotted, &formatted);
504}
505
506/// Apply a TOML **array** leaf. Only list-typed options
507/// ([`crate::ErasedOption::accepts_list`], e.g. the `ui.modeline.*`
508/// zone options) accept arrays; the array's scalar elements are joined
509/// with `,` into the delimited string the option's
510/// [`crate::OptionType::parse`] consumes (ML.5). For a scalar option or
511/// an unknown key, an array stays the same "not applicable" warning as
512/// before — never a panic. A nested array / inline-table element also
513/// warns (list elements must be scalars in v1).
514fn apply_array(
515    registry: &ConfigRegistry,
516    source: &Path,
517    out: &mut LoadOutcome,
518    dotted: &str,
519    items: &[toml::Value],
520) {
521    let accepts_list = registry
522        .lookup(dotted)
523        .map(|opt| opt.accepts_list())
524        .unwrap_or(false);
525    if !accepts_list {
526        out.messages.push(LoadMessage {
527            level: LoadMessageLevel::Warning,
528            source: source.to_path_buf(),
529            body: format!(
530                "`{dotted}`: list / inline-table values aren't \
531                 applicable to scalar options; move it under a \
532                 structural section",
533            ),
534            option: Some(dotted.to_string()),
535        });
536        return;
537    }
538    let mut parts = Vec::with_capacity(items.len());
539    for item in items {
540        match format_scalar(item) {
541            Some(s) => parts.push(s),
542            None => {
543                out.messages.push(LoadMessage {
544                    level: LoadMessageLevel::Warning,
545                    source: source.to_path_buf(),
546                    body: format!(
547                        "`{dotted}`: nested list / table values aren't \
548                         valid list elements",
549                    ),
550                    option: Some(dotted.to_string()),
551                });
552                return;
553            }
554        }
555    }
556    // Element ids never contain commas/spaces, so a comma join is
557    // unambiguous and round-trips through the option's `parse`.
558    let joined = parts.join(",");
559    apply_assignment(registry, source, out, dotted, &joined);
560}
561
562/// Drive one `dotted=value` assignment against the registry, capturing
563/// every failure mode (unknown option, validation reject, parse error)
564/// as a warning. Shared by the scalar + array leaf paths.
565fn apply_assignment(
566    registry: &ConfigRegistry,
567    source: &Path,
568    out: &mut LoadOutcome,
569    dotted: &str,
570    value: &str,
571) {
572    let assign = format!("{dotted}={value}");
573    if let Err(err) = registry.parse_and_set_command(&assign) {
574        let body = match err {
575            ConfigError::UnknownOption(name) => format!("unknown option `{name}`"),
576            ConfigError::Validation(msg) => format!("`{dotted}`: {msg}"),
577            ConfigError::Parse(msg) => format!("`{dotted}`: parse error: {msg}"),
578            other => format!("`{dotted}`: {other}"),
579        };
580        out.messages.push(LoadMessage {
581            level: LoadMessageLevel::Warning,
582            source: source.to_path_buf(),
583            body,
584            option: Some(dotted.to_string()),
585        });
586    }
587}
588
589/// TC.2 — apply `value` as a whole tree if `dotted` names a registered option
590/// whose schema is composite. Returns `true` when it handled the key (applied
591/// or warned), `false` when the caller should fall through to its existing
592/// scalar / namespace / array handling.
593///
594/// This is the slice's entire behavioural change, and the reason it is a
595/// *predicate on the option* rather than on the TOML shape: what makes
596/// `[[org.capture-templates]]` a value and `[completion.per-language]` a
597/// namespace is not how they are written — both are tables — but whether an
598/// option by that exact name exists and says it has structure.
599fn apply_tree_if_composite(
600    registry: &ConfigRegistry,
601    source: &Path,
602    out: &mut LoadOutcome,
603    dotted: &str,
604    value: &toml::Value,
605) -> bool {
606    let Some(opt) = registry.lookup(dotted) else {
607        return false;
608    };
609    let schema = opt.schema();
610    if !schema.is_composite() {
611        return false;
612    }
613    let tree = match toml_to_config_value(value) {
614        Ok(v) => v,
615        Err(err) => {
616            out.messages.push(LoadMessage {
617                level: LoadMessageLevel::Warning,
618                source: source.to_path_buf(),
619                body: format!("`{dotted}{}`: {}", err.path, err.message),
620                option: Some(dotted.to_string()),
621            });
622            return true;
623        }
624    };
625    // Validate here as well as inside `set_value`, because only here is the
626    // error still STRUCTURED — the loader can splice the schema path onto the
627    // option's dotted name and report
628    // `org.capture-templates[2].target.file: expected string, got integer`,
629    // where a flattened string would have read `org.capture-templates:
630    // [2].target.file: …`. The path is the whole user-facing win of this work;
631    // one redundant walk of a cold-path config value is a fair price for it.
632    if let Err(err) = crate::schema::validate(&schema, &tree) {
633        out.messages.push(LoadMessage {
634            level: LoadMessageLevel::Warning,
635            source: source.to_path_buf(),
636            body: format!("`{dotted}{}`: {}", dot_path(&err.path), err.message),
637            option: Some(dotted.to_string()),
638        });
639        return true;
640    }
641    if let Err(err) = opt.set_value(&tree) {
642        out.messages.push(LoadMessage {
643            level: LoadMessageLevel::Warning,
644            source: source.to_path_buf(),
645            body: format!("`{dotted}`: {err}"),
646            option: Some(dotted.to_string()),
647        });
648    }
649    true
650}
651
652/// Render a scalar TOML value as a string the registry's
653/// per-option parser will accept. `None` for shapes (arrays,
654/// inline tables) that aren't a scalar option value.
655fn format_scalar(value: &toml::Value) -> Option<String> {
656    match value {
657        toml::Value::String(s) => Some(s.clone()),
658        toml::Value::Integer(i) => Some(i.to_string()),
659        toml::Value::Float(f) => Some(f.to_string()),
660        toml::Value::Boolean(b) => Some(if *b { "true".into() } else { "false".into() }),
661        toml::Value::Datetime(d) => Some(d.to_string()),
662        toml::Value::Array(_) | toml::Value::Table(_) => None,
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    #![allow(clippy::unwrap_used, clippy::panic)]
669    use super::*;
670    use crate::option::Option as ConfigOption;
671    // TC.2's composite fixture builds trees directly; the loader itself only
672    // ever names these through `crate::schema::`.
673    use crate::schema::ConfigValue;
674
675    #[test]
676    fn config_home_prefers_absolute_xdg_override() {
677        // An absolute $XDG_CONFIG_HOME wins over the platform fallback,
678        // on every platform. "Absolute" is the platform's own notion:
679        // `/custom/xdg` has no drive on Windows, so it is relative there and
680        // the override is — correctly — ignored.
681        let custom = if cfg!(windows) {
682            r"C:\custom\xdg"
683        } else {
684            "/custom/xdg"
685        };
686        let got = resolve_config_home(
687            Some(PathBuf::from(custom)),
688            Some(PathBuf::from("/home/u/.config")),
689        );
690        assert_eq!(got, Some(PathBuf::from(custom)));
691    }
692
693    #[test]
694    fn config_home_ignores_relative_or_empty_xdg() {
695        // A set-but-relative $XDG_CONFIG_HOME is spec-invalid → fall back.
696        let fallback = Some(PathBuf::from("/home/u/.config"));
697        assert_eq!(
698            resolve_config_home(Some(PathBuf::from("relative/path")), fallback.clone()),
699            fallback
700        );
701        // Empty value → PathBuf::from("") is not absolute → fall back.
702        assert_eq!(
703            resolve_config_home(Some(PathBuf::from("")), fallback.clone()),
704            fallback
705        );
706    }
707
708    #[test]
709    fn config_home_falls_back_when_no_override() {
710        let fallback = Some(PathBuf::from("/home/u/.config"));
711        assert_eq!(resolve_config_home(None, fallback.clone()), fallback);
712    }
713
714    #[test]
715    fn config_home_is_none_when_nothing_resolves() {
716        assert_eq!(resolve_config_home(None, None), None);
717    }
718
719    #[test]
720    fn default_user_config_path_ends_with_xdg_lattice_toml() {
721        // The public entry point composes <config_home>/lattice/lattice.toml.
722        // We can't assert the absolute prefix (env-dependent), but the tail is
723        // stable and proves we no longer hard-code the platform-native dir.
724        if let Some(p) = default_user_config_path() {
725            assert!(
726                p.ends_with("lattice/lattice.toml"),
727                "expected .../lattice/lattice.toml, got {p:?}"
728            );
729        }
730    }
731
732    fn registry_with_options() -> ConfigRegistry {
733        let r = ConfigRegistry::new();
734        r.register(ConfigOption::<bool>::new(
735            "number",
736            true,
737            "Show absolute line numbers.",
738        ));
739        r.register(ConfigOption::<i64>::new("tabstop", 8, "Tab width."));
740        r.register(
741            ConfigOption::<i64>::builder("scrolloff", 0, "Scroll-off margin.")
742                .validate(|i| {
743                    if (0..=64).contains(i) {
744                        Ok(())
745                    } else {
746                        Err(format!("scrolloff out of range [0, 64]: {i}"))
747                    }
748                })
749                .build(),
750        );
751        r.register(ConfigOption::<String>::new(
752            "ui.separator",
753            "│".into(),
754            "Pane separator glyph.",
755        ));
756        r
757    }
758
759    fn write_temp(name: &str, contents: &str) -> PathBuf {
760        let dir = std::env::temp_dir().join(format!(
761            "lattice-loader-test-{}-{}",
762            std::process::id(),
763            name,
764        ));
765        let _ = std::fs::remove_dir_all(&dir);
766        std::fs::create_dir_all(&dir).unwrap();
767        let path = dir.join("lattice.toml");
768        std::fs::write(&path, contents).unwrap();
769        path
770    }
771
772    // ── TC.2 fixture: a composite-schema option ───────────────────
773    //
774    // Modelled on org's `capture-templates`, which is the option that
775    // proves the point: a LIST of RECORDS, one of which nests another
776    // record. Nothing in the workspace has this shape yet (that is
777    // phase 3's job), and waiting for it would mean the loader change
778    // landed with no test of the case it exists for.
779
780    #[derive(Debug, Clone, PartialEq, Eq)]
781    struct Template {
782        key: String,
783        target: String,
784        body: Option<String>,
785    }
786
787    #[derive(Debug, Clone, PartialEq, Eq, Default)]
788    struct Templates(Vec<Template>);
789
790    impl crate::OptionType for Templates {
791        // The `:set` text surface. A composite keeps `parse`/`format`
792        // round-tripping — the trait's contract does not get a
793        // exemption for having structure — over a compact
794        // `key>target` form. What the real migration spells here is
795        // its own call (typed-configuration.md §2.2); what matters to
796        // the loader is that it never touches this path.
797        fn parse(s: &str) -> Result<Self, String> {
798            if s.is_empty() {
799                return Ok(Templates(Vec::new()));
800            }
801            s.split(';')
802                .map(|item| {
803                    let (key, target) = item
804                        .split_once('>')
805                        .ok_or_else(|| format!("expected `key>target`, got `{item}`"))?;
806                    Ok(Template {
807                        key: key.to_string(),
808                        target: target.to_string(),
809                        body: None,
810                    })
811                })
812                .collect::<Result<Vec<_>, String>>()
813                .map(Templates)
814        }
815
816        fn format(&self) -> String {
817            self.0
818                .iter()
819                .map(|t| format!("{}>{}", t.key, t.target))
820                .collect::<Vec<_>>()
821                .join(";")
822        }
823
824        fn type_label() -> &'static str {
825            "templates"
826        }
827
828        fn schema() -> crate::ConfigSchema {
829            use crate::{ConfigSchema, SchemaField};
830            ConfigSchema::list(ConfigSchema::record([
831                SchemaField::new("key", ConfigSchema::string(), "the key to press"),
832                SchemaField::new(
833                    "target",
834                    ConfigSchema::record([SchemaField::new(
835                        "file",
836                        ConfigSchema::string(),
837                        "where it lands",
838                    )]),
839                    "where the capture goes",
840                ),
841                SchemaField::new("body", ConfigSchema::string(), "template body").optional(),
842            ]))
843        }
844
845        fn to_value(&self) -> ConfigValue {
846            ConfigValue::List(
847                self.0
848                    .iter()
849                    .map(|t| {
850                        let mut fields = vec![
851                            ("key".to_string(), ConfigValue::Str(t.key.clone())),
852                            (
853                                "target".to_string(),
854                                ConfigValue::record([(
855                                    "file".to_string(),
856                                    ConfigValue::Str(t.target.clone()),
857                                )]),
858                            ),
859                        ];
860                        if let Some(b) = &t.body {
861                            fields.push(("body".to_string(), ConfigValue::Str(b.clone())));
862                        }
863                        ConfigValue::record(fields)
864                    })
865                    .collect(),
866            )
867        }
868
869        fn from_value(value: &ConfigValue) -> Result<Self, String> {
870            let items = value
871                .as_list()
872                .ok_or_else(|| format!("expected list, got {}", value.kind_label()))?;
873            items
874                .iter()
875                .map(|item| {
876                    let key = item
877                        .field("key")
878                        .and_then(ConfigValue::as_str)
879                        .ok_or("missing `key`")?
880                        .to_string();
881                    let target = item
882                        .field("target")
883                        .and_then(|t| t.field("file"))
884                        .and_then(ConfigValue::as_str)
885                        .ok_or("missing `target.file`")?
886                        .to_string();
887                    let body = item
888                        .field("body")
889                        .and_then(ConfigValue::as_str)
890                        .map(str::to_string);
891                    Ok(Template { key, target, body })
892                })
893                .collect::<Result<Vec<_>, String>>()
894                .map(Templates)
895        }
896    }
897
898    fn registry_with_a_composite_option() -> ConfigRegistry {
899        let r = registry_with_options();
900        r.register(ConfigOption::<Templates>::new(
901            "org.capture-templates",
902            Templates::default(),
903            "Capture templates.",
904        ));
905        r
906    }
907
908    fn templates_of(r: &ConfigRegistry) -> Templates {
909        let opt = r.lookup("org.capture-templates").unwrap();
910        <Templates as crate::OptionType>::from_value(&opt.get_value()).unwrap()
911    }
912
913    #[test]
914    fn an_array_of_tables_lands_as_a_composite_options_value() {
915        // The case the blob existed to work around. Before TC.2 this
916        // produced "list / inline-table values aren't applicable to
917        // scalar options"; org's answer was to make the whole thing a
918        // string containing TOML.
919        let r = registry_with_a_composite_option();
920        let p = write_temp(
921            "composite-array-of-tables",
922            "[[org.capture-templates]]\n\
923             key = \"t\"\n\
924             target = { file = \"~/org/refile.org\" }\n\
925             body = \"\"\"\n\
926             * TODO %?\n\
927             \"\"\"\n\
928             \n\
929             [[org.capture-templates]]\n\
930             key = \"n\"\n\
931             target = { file = \"~/org/notes.org\" }\n",
932        );
933        let out = load_file(&r, &p, &[]);
934        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
935
936        let got = templates_of(&r);
937        assert_eq!(got.0.len(), 2);
938        assert_eq!(got.0[0].key, "t");
939        assert_eq!(got.0[0].target, "~/org/refile.org");
940        // The multi-line body survives verbatim — the field the blob
941        // handled WELL (a `'''` literal preserves newlines), and so
942        // the one a tree could plausibly regress. TOML's `"""` eats
943        // the newline immediately after the opening delimiter, which
944        // is the format's rule and not the tree's doing.
945        assert_eq!(got.0[0].body.as_deref(), Some("* TODO %?\n"));
946        assert_eq!(got.0[1].key, "n");
947        assert_eq!(got.0[1].body, None, "an absent optional field stays absent");
948    }
949
950    #[test]
951    fn a_table_at_an_options_name_is_its_value_not_a_namespace() {
952        // A record-shaped option written as a section. Without the
953        // check, `walk_table` descends and applies `key` and `target`
954        // as options in their own right — two `unknown option`
955        // warnings and no value set.
956        let r = registry_with_options();
957        r.register(ConfigOption::<Templates>::new(
958            "org.one-template",
959            Templates::default(),
960            "",
961        ));
962        // A single record still has to satisfy `list<record>`, so this
963        // is the WRONG shape — and the point of the assertion is that
964        // the loader says so about the option rather than inventing
965        // two options that do not exist.
966        let p = write_temp(
967            "composite-table",
968            "[org.one-template]\nkey = \"t\"\ntarget = { file = \"a.org\" }\n",
969        );
970        let out = load_file(&r, &p, &[]);
971        assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
972        let body = &out.messages[0].body;
973        assert!(body.contains("org.one-template"), "{body}");
974        assert!(body.contains("expected list"), "{body}");
975        assert!(
976            !body.contains("unknown option"),
977            "the fields must not be read as options: {body}"
978        );
979    }
980
981    #[test]
982    fn a_shape_mismatch_is_reported_with_its_path() {
983        // The user-facing win of the whole design, and the assertion is
984        // on the PATH rather than on rejection: rejecting without
985        // saying where is exactly what the hand-rolled parsers did.
986        let r = registry_with_a_composite_option();
987        let p = write_temp(
988            "composite-bad-leaf",
989            "[[org.capture-templates]]\n\
990             key = \"t\"\n\
991             target = { file = \"a.org\" }\n\
992             \n\
993             [[org.capture-templates]]\n\
994             key = \"n\"\n\
995             target = { file = 7 }\n",
996        );
997        let out = load_file(&r, &p, &[]);
998        assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
999        let body = &out.messages[0].body;
1000        assert!(
1001            body.contains("org.capture-templates[1].target.file"),
1002            "the path must name the index AND the field: {body}"
1003        );
1004        assert!(body.contains("expected string"), "{body}");
1005        assert!(body.contains("integer"), "{body}");
1006        // Nothing was committed — a partially-applied list is worse
1007        // than a refused one, because half a config reads as a bug in
1008        // the feature rather than a typo in the file.
1009        assert_eq!(templates_of(&r).0.len(), 0);
1010    }
1011
1012    #[test]
1013    fn a_misspelled_field_names_the_key_and_the_alternatives() {
1014        let r = registry_with_a_composite_option();
1015        let p = write_temp(
1016            "composite-typo",
1017            "[[org.capture-templates]]\n\
1018             key = \"t\"\n\
1019             target = { file = \"a.org\" }\n\
1020             bodyy = \"oops\"\n",
1021        );
1022        let out = load_file(&r, &p, &[]);
1023        assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
1024        let body = &out.messages[0].body;
1025        assert!(body.contains("org.capture-templates[0].bodyy"), "{body}");
1026        assert!(body.contains("unknown field"), "{body}");
1027        assert!(body.contains("body"), "{body}");
1028    }
1029
1030    #[test]
1031    fn a_float_is_refused_by_name_rather_than_stringified() {
1032        // `ConfigValue` has no float kind. Quietly rendering `1.5` as
1033        // "1.5" would make the value depend on the host's float
1034        // formatter, which works until something disagrees about it.
1035        let r = registry_with_a_composite_option();
1036        let p = write_temp(
1037            "composite-float",
1038            "[[org.capture-templates]]\nkey = 1.5\ntarget = { file = \"a.org\" }\n",
1039        );
1040        let out = load_file(&r, &p, &[]);
1041        assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
1042        let body = &out.messages[0].body;
1043        assert!(body.contains("org.capture-templates[0].key"), "{body}");
1044        assert!(body.contains("floating-point"), "{body}");
1045    }
1046
1047    #[test]
1048    fn scalar_options_are_untouched_by_the_composite_path() {
1049        // The no-regression half. A table at a SCALAR option's name
1050        // must still take the old warning, and a structural namespace
1051        // must still be captured whole — TC.2 adds a branch, it does
1052        // not re-route the existing ones.
1053        let r = registry_with_a_composite_option();
1054        let p = write_temp(
1055            "composite-no-regression",
1056            "[completion.per-language.markdown]\nsources = \"buffer\"\n\
1057             \n[ui]\nseparator = \"|\"\n\
1058             \n[tabstop]\nnope = 1\n",
1059        );
1060        let out = load_file(&r, &p, &["completion.per-language"]);
1061        assert!(
1062            out.structural
1063                .contains_key("completion.per-language.markdown"),
1064            "a structural namespace is still captured whole: {:?}",
1065            out.structural.keys().collect::<Vec<_>>()
1066        );
1067        assert_eq!(r.lookup("ui.separator").unwrap().get_formatted(), "|");
1068        // `[tabstop]` is a table at a SCALAR option's name: it walks
1069        // in and `tabstop.nope` is unknown, exactly as before.
1070        assert!(
1071            out.messages
1072                .iter()
1073                .any(|m| m.body.contains("unknown option")),
1074            "messages: {:?}",
1075            out.messages
1076        );
1077    }
1078
1079    #[test]
1080    fn missing_file_returns_empty_outcome() {
1081        let r = registry_with_options();
1082        let path = std::env::temp_dir().join("lattice-loader-no-such-file.toml");
1083        let _ = std::fs::remove_file(&path);
1084        let out = load_file(&r, &path, &[]);
1085        // Read failure -> one error message, no panics.
1086        assert_eq!(out.messages.len(), 1);
1087        assert_eq!(out.messages[0].level, LoadMessageLevel::Error);
1088        assert!(out.structural.is_empty());
1089    }
1090
1091    #[test]
1092    fn parses_well_formed_toml_and_writes_scalars() {
1093        let r = registry_with_options();
1094        let p = write_temp(
1095            "well-formed",
1096            "number = false\ntabstop = 4\n[ui]\nseparator = \"|\"\n",
1097        );
1098        let out = load_file(&r, &p, &[]);
1099        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1100        let opt_number = r.lookup("number").unwrap();
1101        assert_eq!(opt_number.get_formatted(), "false");
1102        let opt_tabstop = r.lookup("tabstop").unwrap();
1103        assert_eq!(opt_tabstop.get_formatted(), "4");
1104        let opt_sep = r.lookup("ui.separator").unwrap();
1105        assert_eq!(opt_sep.get_formatted(), "|");
1106    }
1107
1108    #[test]
1109    fn parse_error_emits_one_error_and_stops() {
1110        let r = registry_with_options();
1111        let p = write_temp("malformed", "number = ?broken?\n");
1112        let out = load_file(&r, &p, &[]);
1113        assert_eq!(out.messages.len(), 1);
1114        assert_eq!(out.messages[0].level, LoadMessageLevel::Error);
1115        assert!(out.messages[0].body.contains("parse failed"));
1116        // Default unchanged: no scalars applied past the parse error.
1117        assert_eq!(r.lookup("number").unwrap().get_formatted(), "true");
1118    }
1119
1120    #[test]
1121    fn unknown_key_emits_warning_other_keys_still_apply() {
1122        let r = registry_with_options();
1123        let p = write_temp("unknown", "number = false\nbogus.key = 42\ntabstop = 2\n");
1124        let out = load_file(&r, &p, &[]);
1125        assert_eq!(out.messages.len(), 1);
1126        assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1127        assert!(out.messages[0].body.contains("unknown option"));
1128        assert_eq!(r.lookup("number").unwrap().get_formatted(), "false");
1129        assert_eq!(r.lookup("tabstop").unwrap().get_formatted(), "2");
1130    }
1131
1132    #[test]
1133    fn validation_failure_warns_with_dotted_path_and_skips() {
1134        let r = registry_with_options();
1135        let p = write_temp("invalid", "scrolloff = 999\n");
1136        let out = load_file(&r, &p, &[]);
1137        assert_eq!(out.messages.len(), 1);
1138        assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1139        assert!(out.messages[0].body.contains("scrolloff"));
1140        // Default preserved.
1141        assert_eq!(r.lookup("scrolloff").unwrap().get_formatted(), "0");
1142    }
1143
1144    #[test]
1145    fn list_at_scalar_position_warns_without_panic() {
1146        let r = registry_with_options();
1147        let p = write_temp("list", "number = [1, 2, 3]\n");
1148        let out = load_file(&r, &p, &[]);
1149        assert_eq!(out.messages.len(), 1);
1150        assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1151        assert!(out.messages[0].body.contains("list"));
1152    }
1153
1154    #[test]
1155    fn toml_array_applies_to_a_list_typed_option() {
1156        // ML.5: a TOML array reaches a list-typed option (ModelineZone),
1157        // joined into the option's comma-delimited parse form. Helix
1158        // shape: `[ui.modeline]\nleft = ["core.mode", "core.path"]`.
1159        let r = registry_with_options();
1160        r.register(ConfigOption::<crate::ModelineZone>::new(
1161            "ui.modeline.left",
1162            crate::ModelineZone::Auto,
1163            "Left modeline zone.",
1164        ));
1165        let p = write_temp(
1166            "modeline-array",
1167            "[ui.modeline]\nleft = [\"core.mode\", \"core.path\"]\n",
1168        );
1169        let out = load_file(&r, &p, &[]);
1170        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1171        assert_eq!(
1172            r.lookup("ui.modeline.left").unwrap().get_formatted(),
1173            "core.mode,core.path",
1174        );
1175    }
1176
1177    #[test]
1178    fn empty_toml_array_clears_a_list_typed_zone() {
1179        // `left = []` is an explicitly-blank zone (distinct from the
1180        // `auto` default) — applies as an empty id list.
1181        let r = registry_with_options();
1182        r.register(ConfigOption::<crate::ModelineZone>::new(
1183            "ui.modeline.right",
1184            crate::ModelineZone::Auto,
1185            "Right modeline zone.",
1186        ));
1187        let p = write_temp("modeline-empty", "[ui.modeline]\nright = []\n");
1188        let out = load_file(&r, &p, &[]);
1189        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1190        // Empty id list formats back to the empty string.
1191        assert_eq!(r.lookup("ui.modeline.right").unwrap().get_formatted(), "");
1192    }
1193
1194    #[test]
1195    fn structural_prefix_section_is_buckted_not_walked() {
1196        // `completion.per-language` is structural; loader records
1197        // the markdown sub-table verbatim and does NOT try to
1198        // call parse_and_set on its leaves (which would warn
1199        // "unknown option `completion.per-language.markdown.sources`").
1200        let r = registry_with_options();
1201        let p = write_temp(
1202            "structural",
1203            "[completion.per-language.markdown]\n\
1204             sources = [\"snippet\", \"buffer-words\"]\n\
1205             auto_trigger = false\n",
1206        );
1207        let out = load_file(&r, &p, &["completion.per-language"]);
1208        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1209        let md = out
1210            .structural
1211            .get("completion.per-language.markdown")
1212            .expect("markdown sub-table recorded");
1213        assert!(md.contains_key("sources"));
1214        assert!(md.contains_key("auto_trigger"));
1215    }
1216
1217    #[test]
1218    fn structural_namespace_with_scalar_child_warns() {
1219        // Namespaces hold sub-tables in v1; a stray scalar at
1220        // namespace level (e.g. `completion.per-language.foo = 1`)
1221        // is a warning, not silent acceptance.
1222        let r = registry_with_options();
1223        let p = write_temp("ns-scalar", "[completion.per-language]\nbroken = 1\n");
1224        let out = load_file(&r, &p, &["completion.per-language"]);
1225        assert_eq!(out.messages.len(), 1);
1226        assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1227        assert!(out.messages[0].body.contains("structural namespace"));
1228        assert!(out.structural.is_empty());
1229    }
1230
1231    #[test]
1232    fn structural_namespace_records_each_child_separately() {
1233        // Two sibling languages -> two structural entries.
1234        let r = registry_with_options();
1235        let p = write_temp(
1236            "two-langs",
1237            "[completion.per-language.markdown]\n\
1238             auto_trigger = false\n\
1239             [completion.per-language.rust]\n\
1240             auto_trigger = true\n",
1241        );
1242        let out = load_file(&r, &p, &["completion.per-language"]);
1243        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1244        assert_eq!(out.structural.len(), 2);
1245        assert!(
1246            out.structural
1247                .contains_key("completion.per-language.markdown")
1248        );
1249        assert!(out.structural.contains_key("completion.per-language.rust"));
1250    }
1251
1252    #[test]
1253    fn structural_section_alongside_scalar_keys_in_same_parent() {
1254        // [completion] has `auto_insert_single = true` AND a
1255        // nested `[completion.per-language.markdown]`. Loader
1256        // applies the scalar AND records the structural section
1257        // independently.
1258        let r = registry_with_options();
1259        r.register(ConfigOption::<bool>::new(
1260            "completion.auto_insert_single",
1261            true,
1262            "",
1263        ));
1264        let p = write_temp(
1265            "mixed",
1266            "[completion]\n\
1267             auto_insert_single = false\n\
1268             [completion.per-language.markdown]\n\
1269             auto_trigger = false\n",
1270        );
1271        let out = load_file(&r, &p, &["completion.per-language"]);
1272        assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1273        assert_eq!(
1274            r.lookup("completion.auto_insert_single")
1275                .unwrap()
1276                .get_formatted(),
1277            "false",
1278        );
1279        assert!(
1280            out.structural
1281                .contains_key("completion.per-language.markdown")
1282        );
1283    }
1284
1285    #[test]
1286    fn extend_merges_messages_and_structural_in_order() {
1287        let mut a = LoadOutcome::default();
1288        a.messages.push(LoadMessage {
1289            level: LoadMessageLevel::Warning,
1290            source: PathBuf::from("/a"),
1291            body: "first".into(),
1292            option: None,
1293        });
1294        a.structural.insert("k1".into(), toml::Table::new());
1295        let mut b = LoadOutcome::default();
1296        b.messages.push(LoadMessage {
1297            level: LoadMessageLevel::Warning,
1298            source: PathBuf::from("/b"),
1299            body: "second".into(),
1300            option: None,
1301        });
1302        b.structural.insert("k2".into(), toml::Table::new());
1303        a.extend(b);
1304        assert_eq!(a.messages.len(), 2);
1305        assert_eq!(a.messages[0].body, "first");
1306        assert_eq!(a.messages[1].body, "second");
1307        assert_eq!(a.structural.len(), 2);
1308    }
1309
1310    #[test]
1311    fn load_file_populates_raw_tree_with_full_parsed_table() {
1312        let r = registry_with_options();
1313        let p = write_temp(
1314            "raw-tree",
1315            "[lsp.rust-analyzer.cargo]\n\
1316             features = [\"foo\", \"bar\"]\n\
1317             [lsp.rust-analyzer.checkOnSave]\n\
1318             enable = true\n",
1319        );
1320        let out = load_file(&r, &p, &["lsp"]);
1321        // Tree carries the full structure even though `lsp` was
1322        // also handled as a structural namespace (the two
1323        // surfaces coexist without conflict).
1324        let features = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.cargo.features")
1325            .expect("features path");
1326        let arr = features.as_array().expect("array");
1327        assert_eq!(arr.len(), 2);
1328        assert_eq!(arr[0].as_str(), Some("foo"));
1329        assert_eq!(arr[1].as_str(), Some("bar"));
1330        let enable = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.checkOnSave.enable")
1331            .expect("enable path");
1332        assert_eq!(enable.as_bool(), Some(true));
1333    }
1334
1335    #[test]
1336    fn extend_deep_merges_raw_trees_preserving_sibling_keys() {
1337        // User config has `lsp.rust-analyzer.checkOnSave = true`.
1338        // Project config has `lsp.rust-analyzer.cargo.features =
1339        // ["proj"]`. Merged tree carries BOTH -- project's edit
1340        // doesn't clobber user's sibling key inside the same
1341        // `[lsp.rust-analyzer]` table.
1342        let r = registry_with_options();
1343        let user = write_temp("deep-user", "[lsp.rust-analyzer]\ncheckOnSave = true\n");
1344        let proj = write_temp(
1345            "deep-proj",
1346            "[lsp.rust-analyzer.cargo]\nfeatures = [\"proj\"]\n",
1347        );
1348        let mut out = load_file(&r, &user, &["lsp"]);
1349        out.extend(load_file(&r, &proj, &["lsp"]));
1350        let check = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.checkOnSave")
1351            .and_then(|v| v.as_bool());
1352        assert_eq!(check, Some(true), "user's checkOnSave preserved");
1353        let features = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.cargo.features")
1354            .and_then(|v| v.as_array())
1355            .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>());
1356        assert_eq!(features, Some(vec!["proj"]), "project's features applied");
1357    }
1358
1359    #[test]
1360    fn extend_overrides_scalars_with_later_values() {
1361        // Both files set the same scalar key -- the later
1362        // (project) value wins.
1363        let r = registry_with_options();
1364        let user = write_temp("scalar-user", "tabstop = 2\n");
1365        let proj = write_temp("scalar-proj", "tabstop = 8\n");
1366        let mut out = load_file(&r, &user, &[]);
1367        out.extend(load_file(&r, &proj, &[]));
1368        let ts = lookup_dotted_path(&out.raw_tree, "tabstop").unwrap();
1369        assert_eq!(ts.as_integer(), Some(8));
1370    }
1371
1372    #[test]
1373    fn lookup_dotted_path_returns_none_for_missing_segments() {
1374        let mut t = toml::Table::new();
1375        t.insert("a".into(), toml::Value::String("hello".into()));
1376        assert!(lookup_dotted_path(&t, "missing").is_none());
1377        // `a` is a string, not a table -- walking past it is None.
1378        assert!(lookup_dotted_path(&t, "a.deeper").is_none());
1379        assert_eq!(
1380            lookup_dotted_path(&t, "a").and_then(|v| v.as_str()),
1381            Some("hello"),
1382        );
1383    }
1384
1385    #[test]
1386    fn project_path_helper_lands_at_dot_lattice_config_toml() {
1387        let p = project_config_path(Path::new("/workspace/foo"));
1388        assert_eq!(p, PathBuf::from("/workspace/foo/.lattice/config.toml"));
1389    }
1390}