Skip to main content

khive_runtime/
engine_config.rs

1//! TOML-based embedding engine configuration for khive.
2//!
3//! Loads `.khive/config.toml` (or `--config` / `KHIVE_CONFIG`) and exposes an
4//! `[[engines]]` array for arbitrary-N embedding engine registration. Falls back
5//! to `KHIVE_EMBEDDING_MODEL` env vars when no config file is present.
6
7use std::path::{Path, PathBuf};
8
9use khive_types::namespace::Namespace;
10use serde::Deserialize;
11use thiserror::Error;
12
13use crate::presentation::OutputFormat;
14
15// ---- Error type ----
16
17/// Errors produced while loading or validating a `KhiveConfig`.
18#[derive(Debug, Error)]
19pub enum ConfigError {
20    #[error("config file I/O: {0}")]
21    Io(#[from] std::io::Error),
22
23    #[error("config TOML parse error in {path}: {source}")]
24    Parse {
25        path: PathBuf,
26        #[source]
27        source: toml::de::Error,
28    },
29
30    #[error("exactly one engine must be marked `default = true`; found {found}")]
31    DefaultCount { found: usize },
32
33    #[error("duplicate engine name: {name:?}")]
34    DuplicateName { name: String },
35
36    #[error(
37        "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
38    )]
39    UnknownModel { name: String, model: String },
40
41    #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
42    InvalidFusionWeight { name: String, value: f64 },
43
44    #[error("actor.id {id:?} is not a valid namespace: {reason}")]
45    InvalidActorId { id: String, reason: String },
46
47    #[error("duplicate backend name: {name:?}")]
48    DuplicateBackendName { name: String },
49
50    #[error(
51        "[packs.{pack}].backend = {backend:?} references an unknown backend; \
52         defined backends: {defined}"
53    )]
54    UnknownPackBackend {
55        pack: String,
56        backend: String,
57        defined: String,
58    },
59
60    #[error(
61        "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
62         remove it from the config or wait for a future release that implements it"
63    )]
64    UnsupportedBackendField { name: String, field: &'static str },
65}
66
67// ---- Config structs ----
68
69/// Configuration for a single embedding engine.
70#[derive(Debug, Clone, Deserialize)]
71pub struct EngineConfig {
72    /// Logical name used to reference this engine in logs and fusion.
73    pub name: String,
74
75    /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
76    ///
77    /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
78    /// recognised short alias handled by `parse_embedding_model_alias`).
79    pub model: String,
80
81    /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
82    /// Exactly one engine in the list must set this. If absent, defaults to `false`.
83    #[serde(default)]
84    pub default: bool,
85
86    /// RRF fusion weight for weighted multi-engine fusion.
87    ///
88    /// Only meaningful when multiple engines are loaded. Must be `> 0` when
89    /// present. `None` means the engine participates in fusion with equal weight
90    /// to other engines that also lack a `fusion_weight`.
91    ///
92    /// For RRF: `fusion_weight` provides per-engine relative importance during
93    /// weighted RRF; it does NOT apply to rank-based unweighted RRF (the weights
94    /// are injected into `FusionStrategy::Weighted` only).
95    pub fusion_weight: Option<f64>,
96
97    /// Expected output dimensionality (optional sanity check).
98    ///
99    /// Not used at runtime — dimensions are authoritative from
100    /// `EmbeddingModel::dimensions()`. Present so operators can document the
101    /// expected shape alongside the model name.
102    pub dims: Option<u32>,
103}
104
105/// Actor configuration — the default namespace / identity for this khive instance.
106///
107/// Corresponds to the `[actor]` TOML section. `id` is used as the
108/// `default_namespace` for gate/attribution policy input. OSS dispatch pins
109/// writes to the shared `local` namespace regardless of this value (ADR-007
110/// Rev 4 Rule 0); cloud deployments derive the namespace from an authenticated
111/// `NamespaceToken` instead.
112///
113/// ```toml
114/// [actor]
115/// id = "lambda:leo"                          # attribution identity (required)
116/// display_name = "Leo global orchestrator"   # human label (optional)
117/// visible_namespaces = ["lambda:khive", "local"]  # widens default read scope (ADR-007 Rev 4 Rule 3b)
118/// ```
119///
120/// `visible_namespaces` is consumed by OSS dispatch to widen the DEFAULT
121/// multi-record read scope to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4
122/// Rule 3b). Writes remain pinned to `'local'`. An explicit `namespace=` request
123/// param is a precise single-namespace escape and is not widened. A cloud gate
124/// may also consult this list as policy input at its own layer.
125#[derive(Debug, Clone, Deserialize, Default)]
126pub struct ActorConfig {
127    /// Namespace identifier used as the default actor for all operations.
128    ///
129    /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
130    /// Defaults to `"local"` when absent — backward-compatible with pre-actor
131    /// deployments.
132    #[serde(default)]
133    pub id: Option<String>,
134
135    /// Optional human-readable label for this actor. Not used by the runtime;
136    /// surfaced in introspection and log output only.
137    #[serde(default)]
138    pub display_name: Option<String>,
139
140    /// Additional namespaces that widen the DEFAULT multi-record read scope
141    /// to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4 Rule 3b). Each string
142    /// must be a valid `Namespace`. Writes remain pinned to `'local'`. An
143    /// explicit `namespace=` request param is a precise escape and is not widened
144    /// by this list. A cloud gate may also consult it as policy input.
145    #[serde(default)]
146    pub visible_namespaces: Option<Vec<String>>,
147
148    /// Namespaces this actor's comm.send/reply may deliver messages INTO
149    /// (outbound, sender-side). Empty by default — cross-namespace delivery
150    /// denied unless explicitly declared. The comm handler uses an ordinary
151    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
152    /// the token itself is NOT type-enforced write-only. The recipient-side
153    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
154    /// a future cloud-path authorization ADR (not yet written).
155    ///
156    /// Each entry must be a valid `Namespace` string; validated at
157    /// config-load time. An empty list preserves the prior deny-all behavior
158    /// for any actor that does not add this field.
159    #[serde(default)]
160    pub allowed_outbound_namespaces: Vec<String>,
161}
162
163// ---- Per-pack backend config (ADR-028) ----
164
165/// Storage backend kind.
166#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
167#[serde(rename_all = "lowercase")]
168pub enum BackendKind {
169    /// SQLite file-backed database (default).
170    #[default]
171    Sqlite,
172    /// In-memory database — for testing only; state is lost on restart.
173    Memory,
174}
175
176/// Configuration for a named storage backend.
177///
178/// Corresponds to a `[[backends]]` entry in `khive.toml`.
179/// When no `[[backends]]` section is present, a single implicit `main` backend
180/// is synthesised from the existing `--db` / `KHIVE_DB` / default-path resolution.
181/// All packs fall back to `main` when their name is absent from `[packs]`.
182///
183/// ```toml
184/// [[backends]]
185/// name = "knowledge"
186/// kind = "sqlite"
187/// path = "~/.khive/knowledge.db"
188/// cache_mb = 128
189/// journal_mode = "wal"
190/// read_only = false
191/// ```
192#[derive(Debug, Clone, Deserialize)]
193pub struct BackendConfig {
194    /// Unique backend name. Referenced by `[packs.<name>].backend`.
195    pub name: String,
196    /// Storage backend kind. Defaults to `sqlite`.
197    #[serde(default)]
198    pub kind: BackendKind,
199    /// Filesystem path for `sqlite` kind. Tilde is expanded to `$HOME`.
200    /// `None` for `memory` kind (path is ignored when present).
201    pub path: Option<std::path::PathBuf>,
202    /// SQLite page-cache size in MiB.
203    pub cache_mb: Option<u32>,
204    /// SQLite journal mode (e.g. `"wal"`).
205    pub journal_mode: Option<String>,
206    /// Open the backend read-only. Defaults to `false`.
207    #[serde(default)]
208    pub read_only: bool,
209}
210
211/// Per-pack backend assignment.
212///
213/// Corresponds to a `[packs.<pack-name>]` entry in `khive.toml`.
214/// Packs whose name is absent from `[packs]` fall back to the `main` backend.
215///
216/// ```toml
217/// [packs.knowledge]
218/// backend = "knowledge"
219/// ```
220#[derive(Debug, Clone, Deserialize)]
221pub struct PackConfig {
222    /// Backend name this pack is assigned to. Must match a `[[backends]].name`.
223    pub backend: String,
224}
225
226/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
227///
228/// Sections consumed today:
229/// - `[[engines]]`: embedding engine declarations
230/// - `[actor]`: default namespace / identity (OSS actor model)
231/// - `[runtime]`: runtime knobs (namespace, brain_profile)
232/// - `[[backends]]`: storage backend declarations (ADR-028)
233/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
234///
235/// Unknown keys are silently ignored by serde — forward-compatible.
236#[derive(Debug, Clone, Deserialize, Default)]
237pub struct KhiveConfig {
238    /// Embedding engine declarations.
239    #[serde(default)]
240    pub engines: Vec<EngineConfig>,
241
242    /// Default actor identity for this khive instance.
243    ///
244    /// When present, `actor.id` feeds configuration identity and gate/attribution
245    /// policy input.  A non-`'local'` `actor.id` is folded into the default READ
246    /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
247    /// multi-record reads return, but never routes writes or sets `default_namespace`.
248    /// Cloud model derives actor identity from an authenticated token.
249    #[serde(default)]
250    pub actor: ActorConfig,
251
252    /// Runtime knobs: namespace overrides, brain profile, etc.
253    #[serde(default)]
254    pub runtime: RuntimeSectionConfig,
255
256    /// Named storage backends (ADR-028).
257    ///
258    /// When absent or empty, a single implicit `main` backend is used and all
259    /// packs share it — identical to pre-ADR-028 behavior.
260    #[serde(default)]
261    pub backends: Vec<BackendConfig>,
262
263    /// Per-pack backend assignments (ADR-028).
264    ///
265    /// Maps pack name to backend name. Packs absent from this map fall back to
266    /// the `main` backend. Validated at load time: every referenced backend name
267    /// must appear in `backends`.
268    #[serde(default)]
269    pub packs: std::collections::HashMap<String, PackConfig>,
270}
271
272/// `[runtime]` section in `khive.toml`.
273///
274/// Carries runtime knobs that mirror the CLI flag / env var tier.
275/// All fields are optional; absent keys fall through to env vars or built-in
276/// defaults.
277#[derive(Debug, Clone, Deserialize, Default)]
278pub struct RuntimeSectionConfig {
279    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
280    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
281    ///
282    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
283    /// namespace-bound profile (via `brain.resolve`) is tried, then the
284    /// global tuning prior is used as the final fallback.
285    #[serde(default)]
286    pub brain_profile: Option<String>,
287
288    /// Default output serialization format (ADR-078).
289    ///
290    /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
291    /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
292    ///
293    /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
294    #[serde(default)]
295    pub default_output_format: Option<OutputFormat>,
296}
297
298impl KhiveConfig {
299    /// Load and validate a `KhiveConfig` from an explicit path.
300    ///
301    /// Search order:
302    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
303    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
304    ///
305    /// The project-local default collocates config with the `khive-test.db` that already
306    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
307    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
308    ///
309    /// If the resolved file does **not exist**, returns `Ok(None)`.
310    /// A missing config is not an error — callers fall back to the env-var path.
311    ///
312    /// If the file exists but cannot be parsed, returns a `ConfigError`.
313    /// After parsing, `validate()` runs and any logical errors are returned.
314    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
315        let resolved = match path {
316            Some(p) => p.to_path_buf(),
317            None => PathBuf::from(".khive/config.toml"),
318        };
319
320        if !resolved.exists() {
321            return Ok(None);
322        }
323
324        let raw = std::fs::read_to_string(&resolved)?;
325        let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
326            path: resolved,
327            source,
328        })?;
329        cfg.validate()?;
330        Ok(Some(cfg))
331    }
332
333    /// Load config with the full resolution order:
334    ///
335    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
336    /// 2. `./khive.toml` (project-local, project root)
337    /// 3. `./.khive/config.toml` (project-local, hidden dir)
338    /// 4. `~/.khive/config.toml` (user-global)
339    ///
340    /// Returns the first file found, or `Ok(None)` when none exist.
341    /// Parse errors are propagated immediately — a malformed config is always
342    /// an error regardless of which tier it came from.
343    pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
344        // Tier 1: explicit path (highest priority).
345        if let Some(p) = path {
346            return Self::load(Some(p));
347        }
348
349        // Tiers 2-4: search project root, hidden dir, user-global.
350        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
351        let home_root = std::env::var_os("HOME").map(PathBuf::from);
352        Self::load_with_roots(&project_root, home_root.as_deref())
353    }
354
355    /// Testable inner search: tiers 2-4, given explicit roots instead of
356    /// reading `cwd` and `HOME` from process state.
357    ///
358    /// - Tier 2: `<project_root>/khive.toml`
359    /// - Tier 3: `<project_root>/.khive/config.toml`
360    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
361    pub(crate) fn load_with_roots(
362        project_root: &Path,
363        home_root: Option<&Path>,
364    ) -> Result<Option<Self>, ConfigError> {
365        // Tier 2: project root khive.toml.
366        let tier2 = project_root.join("khive.toml");
367        if tier2.exists() {
368            return Self::load(Some(&tier2));
369        }
370
371        // Tier 3: project-local hidden dir.
372        let tier3 = project_root.join(".khive/config.toml");
373        if tier3.exists() {
374            return Self::load(Some(&tier3));
375        }
376
377        // Tier 4: user-global ~/.khive/config.toml.
378        if let Some(home) = home_root {
379            let tier4 = home.join(".khive/config.toml");
380            if tier4.exists() {
381                return Self::load(Some(&tier4));
382            }
383        }
384
385        Ok(None)
386    }
387
388    /// Validate the parsed config for logical consistency.
389    ///
390    /// Checks:
391    /// - Exactly one engine has `default = true` (when the list is non-empty).
392    /// - Engine names are unique.
393    /// - `fusion_weight`, when present, is `> 0`.
394    ///
395    /// Model name validity is checked lazily at runtime (the config loader does
396    /// not import `lattice_embed` directly to keep the dep surface minimal).
397    pub fn validate(&self) -> Result<(), ConfigError> {
398        // Validate actor.id when present — an invalid namespace is a startup error,
399        // not a silent fallback.
400        if let Some(id) = self.actor.id.as_deref() {
401            if id.is_empty() {
402                return Err(ConfigError::InvalidActorId {
403                    id: id.to_string(),
404                    reason: "actor.id must not be empty; remove the key or provide a value"
405                        .to_string(),
406                });
407            }
408            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
409                id: id.to_string(),
410                reason: e.to_string(),
411            })?;
412        }
413
414        // Validate actor.visible_namespaces when present.
415        if let Some(ref vis) = self.actor.visible_namespaces {
416            for ns_str in vis {
417                if ns_str.is_empty() {
418                    return Err(ConfigError::InvalidActorId {
419                        id: ns_str.clone(),
420                        reason: "visible_namespaces entries must not be empty".to_string(),
421                    });
422                }
423                Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
424                    id: ns_str.clone(),
425                    reason: format!("invalid visible namespace: {e}"),
426                })?;
427            }
428        }
429
430        // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
431        for ns_str in &self.actor.allowed_outbound_namespaces {
432            if ns_str.is_empty() {
433                return Err(ConfigError::InvalidActorId {
434                    id: ns_str.clone(),
435                    reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
436                });
437            }
438            Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
439                id: ns_str.clone(),
440                reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
441            })?;
442        }
443
444        // Validate [[backends]]: unique names (ADR-028).
445        if !self.backends.is_empty() {
446            let mut seen_backends = std::collections::HashSet::new();
447            for backend in &self.backends {
448                if !seen_backends.insert(backend.name.clone()) {
449                    return Err(ConfigError::DuplicateBackendName {
450                        name: backend.name.clone(),
451                    });
452                }
453
454                // Reject fields that are parsed but not yet supported (ADR-028 §1).
455                // An operator setting cache_mb or journal_mode would get silently
456                // ignored — reject loudly so misconfiguration is caught at startup.
457                if backend.cache_mb.is_some() {
458                    return Err(ConfigError::UnsupportedBackendField {
459                        name: backend.name.clone(),
460                        field: "cache_mb",
461                    });
462                }
463                if backend.journal_mode.is_some() {
464                    return Err(ConfigError::UnsupportedBackendField {
465                        name: backend.name.clone(),
466                        field: "journal_mode",
467                    });
468                }
469            }
470
471            // Validate [packs.<name>].backend references (ADR-028).
472            let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
473            for (pack_name, pack_cfg) in &self.packs {
474                if !defined.contains(&pack_cfg.backend.as_str()) {
475                    return Err(ConfigError::UnknownPackBackend {
476                        pack: pack_name.clone(),
477                        backend: pack_cfg.backend.clone(),
478                        defined: defined.join(", "),
479                    });
480                }
481            }
482        }
483
484        if self.engines.is_empty() {
485            return Ok(());
486        }
487
488        // Unique names
489        let mut seen_names = std::collections::HashSet::new();
490        for engine in &self.engines {
491            if !seen_names.insert(engine.name.clone()) {
492                return Err(ConfigError::DuplicateName {
493                    name: engine.name.clone(),
494                });
495            }
496        }
497
498        // Exactly one default
499        let default_count = self.engines.iter().filter(|e| e.default).count();
500        if default_count != 1 {
501            return Err(ConfigError::DefaultCount {
502                found: default_count,
503            });
504        }
505
506        // Positive, finite fusion_weight when present.
507        // NaN does not satisfy `w <= 0.0`, and positive infinity is unbounded,
508        // so reject all non-finite values explicitly before the range check.
509        for engine in &self.engines {
510            if let Some(w) = engine.fusion_weight {
511                if !w.is_finite() || w <= 0.0 {
512                    return Err(ConfigError::InvalidFusionWeight {
513                        name: engine.name.clone(),
514                        value: w,
515                    });
516                }
517            }
518        }
519
520        Ok(())
521    }
522
523    /// Return the engine flagged `default = true`, or `None` if the list is empty.
524    pub fn default_engine(&self) -> Option<&EngineConfig> {
525        self.engines.iter().find(|e| e.default)
526    }
527}
528
529// ---- Env-var fallback ----
530
531/// Build an in-memory `KhiveConfig` from the legacy env-var path.
532///
533/// Used when no config file is present. Emits `tracing::info!` directing
534/// operators to migrate to `~/.khive/config.toml`.
535///
536/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
537/// engine; additional models become non-default secondary engines.
538pub fn config_from_env() -> KhiveConfig {
539    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
540        .ok()
541        .filter(|s| !s.trim().is_empty());
542    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
543        .ok()
544        .unwrap_or_default();
545    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
546        .into_iter()
547        .filter(|s| !s.is_empty())
548        .collect();
549
550    if primary_model.is_none() && additional.is_empty() {
551        return KhiveConfig::default();
552    }
553
554    tracing::info!(
555        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
556    );
557
558    let mut engines = Vec::new();
559
560    if let Some(model) = primary_model {
561        engines.push(EngineConfig {
562            name: "default".to_string(),
563            model,
564            default: true,
565            fusion_weight: None,
566            dims: None,
567        });
568    }
569
570    for (i, model) in additional.into_iter().enumerate() {
571        engines.push(EngineConfig {
572            name: format!("engine-{}", i + 1),
573            model,
574            default: false,
575            fusion_weight: None,
576            dims: None,
577        });
578    }
579
580    // If no primary was specified but there are additional models, promote the
581    // first additional model as the default so the list stays valid.
582    if !engines.is_empty() && !engines.iter().any(|e| e.default) {
583        engines[0].default = true;
584    }
585
586    KhiveConfig {
587        engines,
588        ..KhiveConfig::default()
589    }
590}
591
592// ---- Tests ----
593
594// INLINE TEST JUSTIFICATION: tests here cover config validation error paths that
595// rely on private ConfigError variants and temp-file helpers shared with the
596// config loader. Moving them to tests/ would require pub-exporting ConfigError
597// internals that are not part of the stable public API.
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    // Helper: write a temp file and return the path.
603    fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
604        let path = dir.path().join("config.toml");
605        std::fs::write(&path, content).unwrap();
606        path
607    }
608
609    // 1. Minimal config parses successfully.
610    #[test]
611    fn test_load_minimal_config() {
612        let dir = tempfile::tempdir().unwrap();
613        let path = write_toml(
614            &dir,
615            r#"
616[[engines]]
617name = "x"
618model = "all-minilm-l6-v2"
619default = true
620"#,
621        );
622        let cfg = KhiveConfig::load(Some(&path))
623            .expect("load should succeed")
624            .expect("file should be found");
625        assert_eq!(cfg.engines.len(), 1);
626        assert_eq!(cfg.engines[0].name, "x");
627        assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
628        assert!(cfg.engines[0].default);
629    }
630
631    // 2. Zero default-flagged engines -> error.
632    #[test]
633    fn test_default_engine_required_when_engines_present() {
634        let dir = tempfile::tempdir().unwrap();
635        let path = write_toml(
636            &dir,
637            r#"
638[[engines]]
639name = "a"
640model = "all-minilm-l6-v2"
641"#,
642        );
643        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
644        assert!(
645            matches!(err, ConfigError::DefaultCount { found: 0 }),
646            "expected DefaultCount {{ found: 0 }}, got {err:?}"
647        );
648    }
649
650    // 3. Two engines both flagged default -> error.
651    #[test]
652    fn test_multiple_default_rejected() {
653        let dir = tempfile::tempdir().unwrap();
654        let path = write_toml(
655            &dir,
656            r#"
657[[engines]]
658name = "a"
659model = "all-minilm-l6-v2"
660default = true
661
662[[engines]]
663name = "b"
664model = "paraphrase-multilingual-minilm-l12-v2"
665default = true
666"#,
667        );
668        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
669        assert!(
670            matches!(err, ConfigError::DefaultCount { found: 2 }),
671            "expected DefaultCount {{ found: 2 }}, got {err:?}"
672        );
673    }
674
675    // 4. Negative or zero fusion_weight -> error.
676    #[test]
677    fn test_fusion_weight_validation() {
678        let dir = tempfile::tempdir().unwrap();
679        let path = write_toml(
680            &dir,
681            r#"
682[[engines]]
683name = "a"
684model = "all-minilm-l6-v2"
685default = true
686fusion_weight = -0.5
687"#,
688        );
689        let err =
690            KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
691        assert!(
692            matches!(err, ConfigError::InvalidFusionWeight { .. }),
693            "expected InvalidFusionWeight, got {err:?}"
694        );
695
696        let path2 = write_toml(
697            &dir,
698            r#"
699[[engines]]
700name = "a"
701model = "all-minilm-l6-v2"
702default = true
703fusion_weight = 0.0
704"#,
705        );
706        let err2 =
707            KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
708        assert!(
709            matches!(err2, ConfigError::InvalidFusionWeight { .. }),
710            "expected InvalidFusionWeight, got {err2:?}"
711        );
712    }
713
714    // 5. File absent + env vars set -> constructs equivalent KhiveConfig.
715    #[test]
716    fn test_env_var_fallback() {
717        let dir = tempfile::tempdir().unwrap();
718        let absent = dir.path().join("missing.toml");
719
720        // File does not exist -> KhiveConfig::load returns None.
721        let loaded = KhiveConfig::load(Some(&absent)).unwrap();
722        assert!(loaded.is_none());
723
724        // With env vars set, config_from_env builds a synthetic config.
725        // We can't set env vars safely in a parallel test suite, so test via
726        // the direct construction path instead.
727        let primary = "all-minilm-l6-v2".to_string();
728        let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
729
730        let mut engines = vec![EngineConfig {
731            name: "default".to_string(),
732            model: primary,
733            default: true,
734            fusion_weight: None,
735            dims: None,
736        }];
737        for (i, model) in additional.into_iter().enumerate() {
738            engines.push(EngineConfig {
739                name: format!("engine-{}", i + 1),
740                model,
741                default: false,
742                fusion_weight: None,
743                dims: None,
744            });
745        }
746        let cfg = KhiveConfig {
747            engines,
748            ..KhiveConfig::default()
749        };
750        cfg.validate().expect("env-derived config should be valid");
751        assert_eq!(cfg.engines.len(), 2);
752        assert!(cfg.default_engine().is_some());
753        assert_eq!(cfg.default_engine().unwrap().name, "default");
754    }
755
756    // 6. File present + env vars set -> file wins; test via RuntimeConfig.
757    #[test]
758    fn test_file_overrides_env() {
759        let dir = tempfile::tempdir().unwrap();
760        let path = write_toml(
761            &dir,
762            r#"
763[[engines]]
764name = "file-engine"
765model = "all-minilm-l6-v2"
766default = true
767"#,
768        );
769
770        // File load succeeds even if env vars would provide a different model.
771        // The caller (RuntimeConfig::from_khive_config) is responsible for
772        // checking whether env vars are also present and emitting the warning.
773        // Here we verify that KhiveConfig::load returns the file config.
774        let cfg = KhiveConfig::load(Some(&path))
775            .expect("load should succeed")
776            .expect("file should be present");
777        assert_eq!(cfg.engines[0].name, "file-engine");
778    }
779
780    // 7. Duplicate engine names -> error.
781    #[test]
782    fn test_duplicate_engine_names_rejected() {
783        let dir = tempfile::tempdir().unwrap();
784        let path = write_toml(
785            &dir,
786            r#"
787[[engines]]
788name = "shared"
789model = "all-minilm-l6-v2"
790default = true
791
792[[engines]]
793name = "shared"
794model = "paraphrase-multilingual-minilm-l12-v2"
795"#,
796        );
797        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
798        assert!(
799            matches!(err, ConfigError::DuplicateName { .. }),
800            "expected DuplicateName, got {err:?}"
801        );
802    }
803
804    // 8. Empty config file -> no engines; validate succeeds.
805    #[test]
806    fn test_empty_config_is_valid() {
807        let dir = tempfile::tempdir().unwrap();
808        let path = write_toml(&dir, "# no engines\n");
809        let cfg = KhiveConfig::load(Some(&path))
810            .expect("load should succeed")
811            .expect("file should be found");
812        assert!(cfg.engines.is_empty());
813        cfg.validate().expect("empty config should be valid");
814    }
815
816    // 9. Multi-engine config with valid positive fusion_weight -> succeeds.
817    #[test]
818    fn test_multi_engine_positive_fusion_weight() {
819        let dir = tempfile::tempdir().unwrap();
820        let path = write_toml(
821            &dir,
822            r#"
823[[engines]]
824name = "primary"
825model = "all-minilm-l6-v2"
826default = true
827fusion_weight = 0.7
828
829[[engines]]
830name = "secondary"
831model = "paraphrase-multilingual-minilm-l12-v2"
832fusion_weight = 0.3
833"#,
834        );
835        let cfg = KhiveConfig::load(Some(&path))
836            .expect("load should succeed")
837            .expect("file should be found");
838        assert_eq!(cfg.engines.len(), 2);
839        assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
840        assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
841    }
842
843    // 10. [actor] section with id -> parsed correctly.
844    #[test]
845    fn test_actor_id_parsed() {
846        let dir = tempfile::tempdir().unwrap();
847        let path = write_toml(
848            &dir,
849            r#"
850[actor]
851id = "lambda:khive"
852display_name = "Ocean's khive lambda"
853"#,
854        );
855        let cfg = KhiveConfig::load(Some(&path))
856            .expect("load should succeed")
857            .expect("file should be found");
858        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
859        assert_eq!(
860            cfg.actor.display_name.as_deref(),
861            Some("Ocean's khive lambda")
862        );
863        assert!(cfg.engines.is_empty());
864    }
865
866    // 11. [actor] section with engines -> both parsed.
867    #[test]
868    fn test_actor_and_engines_together() {
869        let dir = tempfile::tempdir().unwrap();
870        let path = write_toml(
871            &dir,
872            r#"
873[actor]
874id = "lambda:test"
875
876[[engines]]
877name = "default"
878model = "all-minilm-l6-v2"
879default = true
880"#,
881        );
882        let cfg = KhiveConfig::load(Some(&path))
883            .expect("load should succeed")
884            .expect("file should be found");
885        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
886        assert_eq!(cfg.engines.len(), 1);
887    }
888
889    // 12. Missing [actor] section -> defaults to None id (backward compat).
890    #[test]
891    fn test_actor_absent_defaults_to_none() {
892        let dir = tempfile::tempdir().unwrap();
893        let path = write_toml(
894            &dir,
895            r#"
896[[engines]]
897name = "x"
898model = "all-minilm-l6-v2"
899default = true
900"#,
901        );
902        let cfg = KhiveConfig::load(Some(&path))
903            .expect("load should succeed")
904            .expect("file should be found");
905        assert!(
906            cfg.actor.id.is_none(),
907            "actor.id must be None when [actor] section is absent"
908        );
909    }
910
911    // 13. load_with_roots returns None when no files exist in the given roots.
912    #[test]
913    fn test_load_with_home_fallback_no_files() {
914        let project_dir = tempfile::tempdir().unwrap();
915        let home_dir = tempfile::tempdir().unwrap();
916        let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
917        assert!(
918            result.expect("no error expected").is_none(),
919            "should return None when no config files exist in the given roots"
920        );
921    }
922
923    // 14. load_with_home_fallback explicit path overrides search.
924    #[test]
925    fn test_load_with_home_fallback_explicit_path() {
926        let dir = tempfile::tempdir().unwrap();
927        let path = write_toml(
928            &dir,
929            r#"
930[actor]
931id = "lambda:explicit"
932"#,
933        );
934        let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
935            .expect("no error expected")
936            .expect("file found");
937        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
938    }
939
940    // 15. actor.id with an invalid namespace string -> ConfigError::InvalidActorId at load time.
941    #[test]
942    fn test_invalid_actor_id_rejected_at_load() {
943        let dir = tempfile::tempdir().unwrap();
944        let path = write_toml(
945            &dir,
946            r#"
947[actor]
948id = "bad namespace"
949"#,
950        );
951        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
952        assert!(
953            matches!(err, ConfigError::InvalidActorId { .. }),
954            "expected InvalidActorId, got {err:?}"
955        );
956    }
957
958    // 16. actor.id = "" (empty string) -> ConfigError::InvalidActorId.
959    #[test]
960    fn test_empty_actor_id_rejected() {
961        let dir = tempfile::tempdir().unwrap();
962        let path = write_toml(
963            &dir,
964            r#"
965[actor]
966id = ""
967"#,
968        );
969        let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
970        assert!(
971            matches!(err, ConfigError::InvalidActorId { .. }),
972            "expected InvalidActorId for empty string, got {err:?}"
973        );
974    }
975
976    // 17. actor.id = "lambda:" (structurally invalid — no slug) -> ConfigError::InvalidActorId.
977    #[test]
978    fn test_malformed_actor_id_lambda_colon_only() {
979        let dir = tempfile::tempdir().unwrap();
980        let path = write_toml(
981            &dir,
982            r#"
983[actor]
984id = "lambda:"
985"#,
986        );
987        let err =
988            KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
989        assert!(
990            matches!(err, ConfigError::InvalidActorId { .. }),
991            "expected InvalidActorId for 'lambda:', got {err:?}"
992        );
993    }
994
995    // 18. ADR-007 Rev 4 Rule 0: actor.id must NOT become default_namespace — writes
996    //     stay pinned to `local`. A non-`'local'` actor.id IS folded into the
997    //     default READ visible-set (ADR-007 Rev 4 Rule 3b), but that does not affect
998    //     default_namespace. This test asserts the write-routing invariant only.
999    #[test]
1000    fn test_runtime_config_actor_id_does_not_override_namespace() {
1001        use crate::runtime::runtime_config_from_khive_config;
1002        use crate::RuntimeConfig;
1003        use khive_types::namespace::Namespace;
1004
1005        let cfg = KhiveConfig {
1006            engines: vec![],
1007            actor: ActorConfig {
1008                id: Some("lambda:test-actor".to_string()),
1009                display_name: None,
1010                ..Default::default()
1011            },
1012            ..KhiveConfig::default()
1013        };
1014        cfg.validate().expect("valid config");
1015
1016        let base = RuntimeConfig::default();
1017        let result = runtime_config_from_khive_config(&cfg, base);
1018        assert_eq!(
1019            result.default_namespace,
1020            Namespace::local(),
1021            "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1022             writes stay pinned to local"
1023        );
1024        // Also assert the fold-in: actor.id MUST appear in visible_namespaces so that
1025        // default reads widen to {local} ∪ {actor namespace} (ADR-007 Rev 4 Rule 3b,
1026        // config.rs:~444). This is the load-bearing side-effect of the actor id config.
1027        assert!(
1028            result
1029                .visible_namespaces
1030                .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1031            "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1032             got: {:?}",
1033            result.visible_namespaces
1034        );
1035    }
1036
1037    // 19. runtime_config_from_khive_config with no actor preserves base namespace.
1038    #[test]
1039    fn test_runtime_config_no_actor_preserves_base() {
1040        use crate::runtime::runtime_config_from_khive_config;
1041        use crate::RuntimeConfig;
1042        use khive_types::namespace::Namespace;
1043
1044        let cfg = KhiveConfig {
1045            engines: vec![],
1046            actor: ActorConfig {
1047                id: None,
1048                display_name: None,
1049                ..Default::default()
1050            },
1051            ..KhiveConfig::default()
1052        };
1053        cfg.validate().expect("valid config");
1054
1055        let base_ns = Namespace::parse("lambda:base").unwrap();
1056        let base = RuntimeConfig {
1057            default_namespace: base_ns.clone(),
1058            ..RuntimeConfig::default()
1059        };
1060        let result = runtime_config_from_khive_config(&cfg, base);
1061        assert_eq!(
1062            result.default_namespace, base_ns,
1063            "no actor.id must leave base namespace unchanged"
1064        );
1065    }
1066
1067    // 20. load_with_roots: khive.toml (tier 2) wins over .khive/config.toml (tier 3).
1068    #[test]
1069    fn test_load_with_home_fallback_project_root_over_hidden() {
1070        let dir = tempfile::tempdir().unwrap();
1071
1072        // Write .khive/config.toml (tier 3).
1073        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1074        std::fs::write(
1075            dir.path().join(".khive/config.toml"),
1076            "[actor]\nid = \"lambda:hidden\"\n",
1077        )
1078        .unwrap();
1079
1080        // Write khive.toml (tier 2) — should win.
1081        std::fs::write(
1082            dir.path().join("khive.toml"),
1083            "[actor]\nid = \"lambda:project-root\"\n",
1084        )
1085        .unwrap();
1086
1087        let cfg = KhiveConfig::load_with_roots(dir.path(), None)
1088            .expect("no error expected")
1089            .expect("file should be found");
1090        assert_eq!(
1091            cfg.actor.id.as_deref(),
1092            Some("lambda:project-root"),
1093            "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1094        );
1095    }
1096
1097    // 21. load_with_roots: .khive/config.toml (tier 3) wins when khive.toml absent.
1098    #[test]
1099    fn test_load_with_home_fallback_hidden_over_absent_root() {
1100        let dir = tempfile::tempdir().unwrap();
1101
1102        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1103        std::fs::write(
1104            dir.path().join(".khive/config.toml"),
1105            "[actor]\nid = \"lambda:hidden-config\"\n",
1106        )
1107        .unwrap();
1108        // No khive.toml.
1109
1110        let cfg = KhiveConfig::load_with_roots(dir.path(), None)
1111            .expect("no error expected")
1112            .expect("file should be found");
1113        assert_eq!(
1114            cfg.actor.id.as_deref(),
1115            Some("lambda:hidden-config"),
1116            ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1117        );
1118    }
1119
1120    // 22. load_with_roots: ~/.khive/config.toml (tier 4) found when project files absent.
1121    #[test]
1122    fn test_load_with_roots_home_tier_found() {
1123        let project_dir = tempfile::tempdir().unwrap();
1124        let home_dir = tempfile::tempdir().unwrap();
1125
1126        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1127        std::fs::write(
1128            home_dir.path().join(".khive/config.toml"),
1129            "[actor]\nid = \"lambda:user-global\"\n",
1130        )
1131        .unwrap();
1132        // No project-level files.
1133
1134        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
1135            .expect("no error expected")
1136            .expect("file should be found");
1137        assert_eq!(
1138            cfg.actor.id.as_deref(),
1139            Some("lambda:user-global"),
1140            "~/.khive/config.toml (tier 4) must be found when project files absent"
1141        );
1142    }
1143
1144    // 23. load_with_roots: project tier wins over home tier.
1145    #[test]
1146    fn test_load_with_roots_project_wins_over_home() {
1147        let project_dir = tempfile::tempdir().unwrap();
1148        let home_dir = tempfile::tempdir().unwrap();
1149
1150        // Home has a config.
1151        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1152        std::fs::write(
1153            home_dir.path().join(".khive/config.toml"),
1154            "[actor]\nid = \"lambda:user-global\"\n",
1155        )
1156        .unwrap();
1157
1158        // Project also has a config — should win.
1159        std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1160        std::fs::write(
1161            project_dir.path().join(".khive/config.toml"),
1162            "[actor]\nid = \"lambda:project-wins\"\n",
1163        )
1164        .unwrap();
1165
1166        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
1167            .expect("no error expected")
1168            .expect("file should be found");
1169        assert_eq!(
1170            cfg.actor.id.as_deref(),
1171            Some("lambda:project-wins"),
1172            "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1173        );
1174    }
1175
1176    // ── ADR-028 backend / pack config tests ─────────────────────────────────
1177
1178    // 22. No [[backends]] → empty vecs, no validation error.
1179    #[test]
1180    fn test_no_backends_section_is_valid() {
1181        let dir = tempfile::tempdir().unwrap();
1182        let path = write_toml(
1183            &dir,
1184            r#"
1185[[engines]]
1186name = "default"
1187model = "all-minilm-l6-v2"
1188default = true
1189"#,
1190        );
1191        let cfg = KhiveConfig::load(Some(&path))
1192            .expect("no error")
1193            .expect("file found");
1194        assert!(cfg.backends.is_empty());
1195        assert!(cfg.packs.is_empty());
1196    }
1197
1198    // 23. Single Sqlite backend deserializes correctly.
1199    #[test]
1200    fn test_single_sqlite_backend_parses() {
1201        let dir = tempfile::tempdir().unwrap();
1202        let path = write_toml(
1203            &dir,
1204            r#"
1205[[backends]]
1206name = "knowledge"
1207kind = "sqlite"
1208path = "/tmp/knowledge.db"
1209"#,
1210        );
1211        let cfg = KhiveConfig::load(Some(&path))
1212            .expect("no error")
1213            .expect("file found");
1214        assert_eq!(cfg.backends.len(), 1);
1215        let b = &cfg.backends[0];
1216        assert_eq!(b.name, "knowledge");
1217        assert!(matches!(b.kind, BackendKind::Sqlite));
1218        assert_eq!(
1219            b.path.as_ref().and_then(|p| p.to_str()),
1220            Some("/tmp/knowledge.db")
1221        );
1222    }
1223
1224    // 24. Memory backend parses correctly.
1225    #[test]
1226    fn test_memory_backend_parses() {
1227        let dir = tempfile::tempdir().unwrap();
1228        let path = write_toml(
1229            &dir,
1230            r#"
1231[[backends]]
1232name = "ephemeral"
1233kind = "memory"
1234"#,
1235        );
1236        let cfg = KhiveConfig::load(Some(&path))
1237            .expect("no error")
1238            .expect("file found");
1239        assert_eq!(cfg.backends.len(), 1);
1240        assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1241    }
1242
1243    // 25. Pack config backend assignment parses correctly.
1244    #[test]
1245    fn test_pack_backend_assignment_parses() {
1246        let dir = tempfile::tempdir().unwrap();
1247        let path = write_toml(
1248            &dir,
1249            r#"
1250[[backends]]
1251name = "knowledge"
1252kind = "memory"
1253
1254[packs.knowledge]
1255backend = "knowledge"
1256"#,
1257        );
1258        let cfg = KhiveConfig::load(Some(&path))
1259            .expect("no error")
1260            .expect("file found");
1261        assert_eq!(cfg.packs.len(), 1);
1262        let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1263        assert_eq!(pc.backend, "knowledge");
1264    }
1265
1266    // 26. Duplicate backend names → DuplicateBackendName error.
1267    #[test]
1268    fn test_duplicate_backend_name_rejected() {
1269        let dir = tempfile::tempdir().unwrap();
1270        let path = write_toml(
1271            &dir,
1272            r#"
1273[[backends]]
1274name = "dup"
1275kind = "memory"
1276
1277[[backends]]
1278name = "dup"
1279kind = "memory"
1280"#,
1281        );
1282        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1283        assert!(
1284            matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1285            "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1286        );
1287    }
1288
1289    // 27. Pack referencing undefined backend → UnknownPackBackend error.
1290    #[test]
1291    fn test_pack_referencing_undefined_backend_rejected() {
1292        let dir = tempfile::tempdir().unwrap();
1293        let path = write_toml(
1294            &dir,
1295            r#"
1296[[backends]]
1297name = "knowledge"
1298kind = "memory"
1299
1300[packs.kg]
1301backend = "nonexistent"
1302"#,
1303        );
1304        let err =
1305            KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1306        assert!(
1307            matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1308                if pack == "kg" && backend == "nonexistent"),
1309            "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1310        );
1311    }
1312
1313    // 28. Pack config with no [[backends]] → packs section validated only when backends present.
1314    #[test]
1315    fn test_pack_config_without_backends_section_is_allowed() {
1316        let dir = tempfile::tempdir().unwrap();
1317        // Per the spec: when [[backends]] is absent/empty, packs are not validated
1318        // (all packs fall through to the implicit main backend).
1319        let path = write_toml(
1320            &dir,
1321            r#"
1322[packs.kg]
1323backend = "main"
1324"#,
1325        );
1326        // This should succeed: no backends declared → no validation of packs.
1327        let cfg = KhiveConfig::load(Some(&path))
1328            .expect("no error expected")
1329            .expect("file found");
1330        assert_eq!(cfg.backends.len(), 0);
1331        assert_eq!(cfg.packs.len(), 1);
1332    }
1333
1334    // B-SHOULD-FIX-1: cache_mb in [[backends]] must be rejected at validate() with a clear error.
1335    #[test]
1336    fn test_backend_cache_mb_rejected_at_validate() {
1337        let dir = tempfile::tempdir().unwrap();
1338        let path = write_toml(
1339            &dir,
1340            r#"
1341[[backends]]
1342name = "main"
1343kind = "memory"
1344cache_mb = 128
1345"#,
1346        );
1347        let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1348        assert!(
1349            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1350            "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1351        );
1352    }
1353
1354    // B-SHOULD-FIX-1: journal_mode in [[backends]] must be rejected at validate() with a clear error.
1355    #[test]
1356    fn test_backend_journal_mode_rejected_at_validate() {
1357        let dir = tempfile::tempdir().unwrap();
1358        let path = write_toml(
1359            &dir,
1360            r#"
1361[[backends]]
1362name = "main"
1363kind = "memory"
1364journal_mode = "wal"
1365"#,
1366        );
1367        let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1368        assert!(
1369            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1370            "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1371        );
1372    }
1373}