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    #[error(
67        "top-level `db = {value:?}` is not a supported config-file key; \
68         use `--db` / `KHIVE_DB` to select a single-file database, or \
69         `[[backends]].path` to declare storage backend topology"
70    )]
71    UnsupportedTopLevelDb { value: String },
72}
73
74// ---- Config structs ----
75
76/// Configuration for a single embedding engine.
77#[derive(Debug, Clone, Deserialize)]
78pub struct EngineConfig {
79    /// Logical name used to reference this engine in logs and fusion.
80    pub name: String,
81
82    /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
83    ///
84    /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
85    /// recognised short alias handled by `parse_embedding_model_alias`).
86    pub model: String,
87
88    /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
89    /// Exactly one engine in the list must set this. If absent, defaults to `false`.
90    #[serde(default)]
91    pub default: bool,
92
93    /// RRF fusion weight for weighted multi-engine fusion.
94    ///
95    /// Only meaningful when multiple engines are loaded. Must be `> 0` when
96    /// present. `None` means the engine participates in fusion with equal weight
97    /// to other engines that also lack a `fusion_weight`.
98    ///
99    /// For RRF: `fusion_weight` provides per-engine relative importance during
100    /// weighted RRF; it does NOT apply to rank-based unweighted RRF (the weights
101    /// are injected into `FusionStrategy::Weighted` only).
102    pub fusion_weight: Option<f64>,
103
104    /// Expected output dimensionality (optional sanity check).
105    ///
106    /// Not used at runtime — dimensions are authoritative from
107    /// `EmbeddingModel::dimensions()`. Present so operators can document the
108    /// expected shape alongside the model name.
109    pub dims: Option<u32>,
110}
111
112/// Actor configuration — the default namespace / identity for this khive instance.
113///
114/// Corresponds to the `[actor]` TOML section. `id` is used as the
115/// `default_namespace` for gate/attribution policy input. OSS dispatch pins
116/// writes to the shared `local` namespace regardless of this value (ADR-007
117/// Rev 4 Rule 0); cloud deployments derive the namespace from an authenticated
118/// `NamespaceToken` instead.
119///
120/// ```toml
121/// [actor]
122/// id = "lambda:leo"                          # attribution identity (required)
123/// display_name = "example actor"   # human label (optional)
124/// visible_namespaces = ["lambda:khive", "local"]  # widens default read scope (ADR-007 Rev 4 Rule 3b)
125/// ```
126///
127/// `visible_namespaces` is consumed by OSS dispatch to widen the DEFAULT
128/// multi-record read scope to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4
129/// Rule 3b). Writes remain pinned to `'local'`. An explicit `namespace=` request
130/// param is a precise single-namespace escape and is not widened. A cloud gate
131/// may also consult this list as policy input at its own layer.
132#[derive(Debug, Clone, Deserialize, Default)]
133pub struct ActorConfig {
134    /// Namespace identifier used as the default actor for all operations.
135    ///
136    /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
137    /// Defaults to `"local"` when absent — backward-compatible with pre-actor
138    /// deployments.
139    #[serde(default)]
140    pub id: Option<String>,
141
142    /// Optional human-readable label for this actor. Not used by the runtime;
143    /// surfaced in introspection and log output only.
144    #[serde(default)]
145    pub display_name: Option<String>,
146
147    /// Additional namespaces that widen the DEFAULT multi-record read scope
148    /// to `['local'] ∪ visible_namespaces` (ADR-007 Rev 4 Rule 3b). Each string
149    /// must be a valid `Namespace`. Writes remain pinned to `'local'`. An
150    /// explicit `namespace=` request param is a precise escape and is not widened
151    /// by this list. A cloud gate may also consult it as policy input.
152    #[serde(default)]
153    pub visible_namespaces: Option<Vec<String>>,
154
155    /// Namespaces this actor's comm.send/reply may deliver messages INTO
156    /// (outbound, sender-side). Empty by default — cross-namespace delivery
157    /// denied unless explicitly declared. The comm handler uses an ordinary
158    /// `NamespaceToken` (minted via `with_namespace`) in an append-only manner;
159    /// the token itself is NOT type-enforced write-only. The recipient-side
160    /// `allowed_inbound_namespaces` (bilateral mutual opt-in) is reserved for
161    /// a future cloud-path authorization ADR (not yet written).
162    ///
163    /// Each entry must be a valid `Namespace` string; validated at
164    /// config-load time. An empty list preserves the prior deny-all behavior
165    /// for any actor that does not add this field.
166    #[serde(default)]
167    pub allowed_outbound_namespaces: Vec<String>,
168}
169
170// ---- Per-pack backend config (ADR-028) ----
171
172/// Storage backend kind.
173#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
174#[serde(rename_all = "lowercase")]
175pub enum BackendKind {
176    /// SQLite file-backed database (default).
177    #[default]
178    Sqlite,
179    /// In-memory database — for testing only; state is lost on restart.
180    Memory,
181}
182
183/// Configuration for a named storage backend.
184///
185/// Corresponds to a `[[backends]]` entry in `khive.toml`.
186/// When no `[[backends]]` section is present, a single implicit `main` backend
187/// is synthesised from the existing `--db` / `KHIVE_DB` / default-path resolution.
188/// All packs fall back to `main` when their name is absent from `[packs]`.
189///
190/// ```toml
191/// [[backends]]
192/// name = "knowledge"
193/// kind = "sqlite"
194/// path = "~/.khive/knowledge.db"
195/// cache_mb = 128
196/// journal_mode = "wal"
197/// read_only = false
198/// ```
199#[derive(Debug, Clone, Deserialize)]
200pub struct BackendConfig {
201    /// Unique backend name. Referenced by `[packs.<name>].backend`.
202    pub name: String,
203    /// Storage backend kind. Defaults to `sqlite`.
204    #[serde(default)]
205    pub kind: BackendKind,
206    /// Filesystem path for `sqlite` kind. Tilde is expanded to `$HOME`.
207    /// `None` for `memory` kind (path is ignored when present).
208    pub path: Option<std::path::PathBuf>,
209    /// SQLite page-cache size in MiB.
210    pub cache_mb: Option<u32>,
211    /// SQLite journal mode (e.g. `"wal"`).
212    pub journal_mode: Option<String>,
213    /// Open the backend read-only. Defaults to `false`.
214    #[serde(default)]
215    pub read_only: bool,
216}
217
218/// Per-pack backend assignment.
219///
220/// Corresponds to a `[packs.<pack-name>]` entry in `khive.toml`.
221/// Packs whose name is absent from `[packs]` fall back to the `main` backend.
222///
223/// ```toml
224/// [packs.knowledge]
225/// backend = "knowledge"
226/// ```
227#[derive(Debug, Clone, Deserialize)]
228pub struct PackConfig {
229    /// Backend name this pack is assigned to. Must match a `[[backends]].name`.
230    pub backend: String,
231}
232
233/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
234///
235/// Sections consumed today:
236/// - `[[engines]]`: embedding engine declarations
237/// - `[actor]`: default namespace / identity (OSS actor model)
238/// - `[runtime]`: runtime knobs (namespace, brain_profile)
239/// - `[[backends]]`: storage backend declarations (ADR-028)
240/// - `[packs.<name>]`: per-pack backend assignments (ADR-028)
241///
242/// Unknown keys are silently ignored by serde — forward-compatible.
243#[derive(Debug, Clone, Deserialize, Default)]
244pub struct KhiveConfig {
245    /// Typed only so a top-level `db` key can be rejected loudly by
246    /// [`KhiveConfig::validate`] instead of being silently ignored as an
247    /// unknown key. Not a supported config-file storage selector: single-file
248    /// database selection is `--db`/`KHIVE_DB`, and storage topology is
249    /// `[[backends]].path`.
250    #[serde(default)]
251    pub db: Option<String>,
252
253    /// Embedding engine declarations.
254    #[serde(default)]
255    pub engines: Vec<EngineConfig>,
256
257    /// Default actor identity for this khive instance.
258    ///
259    /// When present, `actor.id` feeds configuration identity and gate/attribution
260    /// policy input.  A non-`'local'` `actor.id` is folded into the default READ
261    /// visible-set at config load (ADR-007 Rev 4 Rule 3b) — it widens what default
262    /// multi-record reads return, but never routes writes or sets `default_namespace`.
263    /// Cloud model derives actor identity from an authenticated token.
264    #[serde(default)]
265    pub actor: ActorConfig,
266
267    /// Runtime knobs: namespace overrides, brain profile, etc.
268    #[serde(default)]
269    pub runtime: RuntimeSectionConfig,
270
271    /// Named storage backends (ADR-028).
272    ///
273    /// When absent or empty, a single implicit `main` backend is used and all
274    /// packs share it — identical to pre-ADR-028 behavior.
275    #[serde(default)]
276    pub backends: Vec<BackendConfig>,
277
278    /// Per-pack backend assignments (ADR-028).
279    ///
280    /// Maps pack name to backend name. Packs absent from this map fall back to
281    /// the `main` backend. Validated at load time: every referenced backend name
282    /// must appear in `backends`.
283    #[serde(default)]
284    pub packs: std::collections::HashMap<String, PackConfig>,
285}
286
287/// `[runtime]` section in `khive.toml`.
288///
289/// Carries runtime knobs that mirror the CLI flag / env var tier.
290/// All fields are optional; absent keys fall through to env vars or built-in
291/// defaults.
292#[derive(Debug, Clone, Deserialize, Default)]
293pub struct RuntimeSectionConfig {
294    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
295    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
296    ///
297    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
298    /// namespace-bound profile (via `brain.resolve`) is tried, then the
299    /// global tuning prior is used as the final fallback.
300    #[serde(default)]
301    pub brain_profile: Option<String>,
302
303    /// Default output serialization format (ADR-078).
304    ///
305    /// Mirrors `--output-format` / `KHIVE_OUTPUT_FORMAT`. Precedence (highest to lowest):
306    /// per-request `format` field → `KHIVE_OUTPUT_FORMAT` → this field → builtin `json`.
307    ///
308    /// Accepted values: `"json"` (default), `"auto"`, `"table"`.
309    #[serde(default)]
310    pub default_output_format: Option<OutputFormat>,
311}
312
313impl KhiveConfig {
314    /// Load and validate a `KhiveConfig` from an explicit path.
315    ///
316    /// Search order:
317    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
318    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
319    ///
320    /// The project-local default collocates config with the `khive-test.db` that already
321    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
322    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
323    ///
324    /// If the resolved file does **not exist**, returns `Ok(None)`.
325    /// A missing config is not an error — callers fall back to the env-var path.
326    ///
327    /// If the file exists but cannot be parsed, returns a `ConfigError`.
328    /// After parsing, `validate()` runs and any logical errors are returned.
329    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
330        let resolved = match path {
331            Some(p) => p.to_path_buf(),
332            None => PathBuf::from(".khive/config.toml"),
333        };
334
335        if !resolved.exists() {
336            return Ok(None);
337        }
338
339        let raw = std::fs::read_to_string(&resolved)?;
340        let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
341            path: resolved,
342            source,
343        })?;
344        cfg.validate()?;
345        Ok(Some(cfg))
346    }
347
348    /// Load config with the full resolution order:
349    ///
350    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
351    /// 2. `./khive.toml` (project-local, project root)
352    /// 3. `<db-dir>/config.toml` (project-local, anchored to the resolved database's
353    ///    own directory — see `project_config_anchor_dir`)
354    /// 4. `~/.khive/config.toml` (user-global)
355    ///
356    /// Returns the first file found, or `Ok(None)` when none exist.
357    /// Parse errors are propagated immediately — a malformed config is always
358    /// an error regardless of which tier it came from.
359    ///
360    /// `db_path` should be the same database path the caller is about to open
361    /// (or has already resolved). Passing it makes tier 3 resolve identically
362    /// for any two processes that target the same database, regardless of
363    /// their process working directory — this is what lets a thin client and
364    /// a warm daemon serving the same database agree on one config file. Pass
365    /// `None` when no database path is known yet; tier 3 then falls back to
366    /// the process cwd, matching the pre-existing behavior.
367    pub fn load_with_home_fallback(
368        path: Option<&Path>,
369        db_path: Option<&Path>,
370    ) -> Result<Option<Self>, ConfigError> {
371        // Tier 1: explicit path (highest priority).
372        if let Some(p) = path {
373            return Self::load(Some(p));
374        }
375
376        // Tiers 2-4: search project root, db-anchored hidden dir, user-global.
377        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
378        let home_root = std::env::var_os("HOME").map(PathBuf::from);
379        Self::load_with_roots(&project_root, home_root.as_deref(), db_path)
380    }
381
382    /// Testable inner search: tiers 2-4, given explicit roots instead of
383    /// reading `cwd` and `HOME` from process state.
384    ///
385    /// - Tier 2: `<project_root>/khive.toml` (still cwd-anchored — unchanged)
386    /// - Tier 3: `<db_dir>/config.toml`, anchored to `db_path` rather than
387    ///   `project_root` (see `project_config_anchor_dir`); falls back to
388    ///   `<project_root>/.khive/config.toml` when `db_path` is `None`
389    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
390    pub(crate) fn load_with_roots(
391        project_root: &Path,
392        home_root: Option<&Path>,
393        db_path: Option<&Path>,
394    ) -> Result<Option<Self>, ConfigError> {
395        // Tier 2: project root khive.toml.
396        let tier2 = project_root.join("khive.toml");
397        if tier2.exists() {
398            return Self::load(Some(&tier2));
399        }
400
401        // Tier 3: project-local hidden dir, anchored to the resolved database's
402        // own directory instead of the process cwd.
403        let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
404        if tier3.exists() {
405            return Self::load(Some(&tier3));
406        }
407
408        // Tier 4: user-global ~/.khive/config.toml.
409        if let Some(home) = home_root {
410            let tier4 = home.join(".khive/config.toml");
411            if tier4.exists() {
412                return Self::load(Some(&tier4));
413            }
414        }
415
416        Ok(None)
417    }
418
419    /// Resolve the directory searched for the tier-3 project-local config file.
420    ///
421    /// Anchored to the directory containing the resolved database file, not the
422    /// process cwd: two processes at different working directories that open the
423    /// same database agree on this directory, which is what keeps their
424    /// `config_id` fingerprints in sync (a client and a warm daemon serving the
425    /// same database must resolve identical config so the daemon accepts the
426    /// client's forwarded requests instead of rejecting them on a config
427    /// mismatch).
428    ///
429    /// `db_path` is canonicalized first so symlinks/relative components collapse
430    /// to the same absolute directory regardless of caller cwd. The database file
431    /// may not exist yet (first run before anything has been written) — in that
432    /// case canonicalization fails and the path is absolutized against
433    /// `project_root` instead (or used as-is if already absolute); this must
434    /// never panic, it is the expected cold-start case.
435    ///
436    /// If `db_dir` (the resolved database's parent directory) is itself named
437    /// `.khive`, the config lives directly inside it (`<db_dir>/config.toml`) —
438    /// this is the common case where the database is `<root>/.khive/khive.db`.
439    /// Otherwise the config lives in a `.khive` subdirectory of `db_dir`.
440    ///
441    /// `db_path == None` (e.g. an in-memory database, or no database path known
442    /// yet) falls back to `<project_root>/.khive`, preserving the pre-existing
443    /// cwd-anchored behavior for callers with no database to anchor on.
444    fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
445        let Some(db_path) = db_path else {
446            return project_root.join(".khive");
447        };
448
449        let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
450            if db_path.is_absolute() {
451                db_path.to_path_buf()
452            } else {
453                project_root.join(db_path)
454            }
455        });
456
457        let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
458
459        if db_dir.file_name().is_some_and(|name| name == ".khive") {
460            db_dir
461        } else {
462            db_dir.join(".khive")
463        }
464    }
465
466    /// Validate the parsed config for logical consistency.
467    ///
468    /// Checks:
469    /// - Exactly one engine has `default = true` (when the list is non-empty).
470    /// - Engine names are unique.
471    /// - `fusion_weight`, when present, is `> 0`.
472    ///
473    /// Model name validity is checked lazily at runtime (the config loader does
474    /// not import `lattice_embed` directly to keep the dep surface minimal).
475    pub fn validate(&self) -> Result<(), ConfigError> {
476        // Reject a top-level `db` key loudly instead of letting serde's
477        // forward-compatible unknown-key tolerance silently swallow it: a
478        // config author expecting `db=` to select the database would
479        // otherwise get silent divergence from `--db`/`KHIVE_DB`.
480        if let Some(value) = self.db.as_deref() {
481            if !value.is_empty() {
482                return Err(ConfigError::UnsupportedTopLevelDb {
483                    value: value.to_string(),
484                });
485            }
486        }
487
488        // Validate actor.id when present — an invalid namespace is a startup error,
489        // not a silent fallback.
490        if let Some(id) = self.actor.id.as_deref() {
491            if id.is_empty() {
492                return Err(ConfigError::InvalidActorId {
493                    id: id.to_string(),
494                    reason: "actor.id must not be empty; remove the key or provide a value"
495                        .to_string(),
496                });
497            }
498            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
499                id: id.to_string(),
500                reason: e.to_string(),
501            })?;
502        }
503
504        if let Some(ref vis) = self.actor.visible_namespaces {
505            for ns_str in vis {
506                if ns_str.is_empty() {
507                    return Err(ConfigError::InvalidActorId {
508                        id: ns_str.clone(),
509                        reason: "visible_namespaces entries must not be empty".to_string(),
510                    });
511                }
512                Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
513                    id: ns_str.clone(),
514                    reason: format!("invalid visible namespace: {e}"),
515                })?;
516            }
517        }
518
519        // Validate actor.allowed_outbound_namespaces (fail-closed at startup on malformed entry).
520        for ns_str in &self.actor.allowed_outbound_namespaces {
521            if ns_str.is_empty() {
522                return Err(ConfigError::InvalidActorId {
523                    id: ns_str.clone(),
524                    reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
525                });
526            }
527            Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
528                id: ns_str.clone(),
529                reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
530            })?;
531        }
532
533        // Backend names must be unique.
534        if !self.backends.is_empty() {
535            let mut seen_backends = std::collections::HashSet::new();
536            for backend in &self.backends {
537                if !seen_backends.insert(backend.name.clone()) {
538                    return Err(ConfigError::DuplicateBackendName {
539                        name: backend.name.clone(),
540                    });
541                }
542
543                // Reject fields that are parsed but not yet implemented: silently
544                // accepting them would let misconfiguration slip past startup.
545                if backend.cache_mb.is_some() {
546                    return Err(ConfigError::UnsupportedBackendField {
547                        name: backend.name.clone(),
548                        field: "cache_mb",
549                    });
550                }
551                if backend.journal_mode.is_some() {
552                    return Err(ConfigError::UnsupportedBackendField {
553                        name: backend.name.clone(),
554                        field: "journal_mode",
555                    });
556                }
557            }
558
559            // Every pack-referenced backend name must be declared in `backends`.
560            let defined: Vec<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
561            for (pack_name, pack_cfg) in &self.packs {
562                if !defined.contains(&pack_cfg.backend.as_str()) {
563                    return Err(ConfigError::UnknownPackBackend {
564                        pack: pack_name.clone(),
565                        backend: pack_cfg.backend.clone(),
566                        defined: defined.join(", "),
567                    });
568                }
569            }
570        }
571
572        if self.engines.is_empty() {
573            return Ok(());
574        }
575
576        let mut seen_names = std::collections::HashSet::new();
577        for engine in &self.engines {
578            if !seen_names.insert(engine.name.clone()) {
579                return Err(ConfigError::DuplicateName {
580                    name: engine.name.clone(),
581                });
582            }
583        }
584
585        let default_count = self.engines.iter().filter(|e| e.default).count();
586        if default_count != 1 {
587            return Err(ConfigError::DefaultCount {
588                found: default_count,
589            });
590        }
591
592        // Reject non-finite fusion_weight explicitly: NaN doesn't satisfy `w <= 0.0`
593        // and +inf is unbounded, so neither is caught by the range check alone.
594        for engine in &self.engines {
595            if let Some(w) = engine.fusion_weight {
596                if !w.is_finite() || w <= 0.0 {
597                    return Err(ConfigError::InvalidFusionWeight {
598                        name: engine.name.clone(),
599                        value: w,
600                    });
601                }
602            }
603        }
604
605        Ok(())
606    }
607
608    /// Return the engine flagged `default = true`, or `None` if the list is empty.
609    pub fn default_engine(&self) -> Option<&EngineConfig> {
610        self.engines.iter().find(|e| e.default)
611    }
612}
613
614// ---- Env-var fallback ----
615
616/// Build an in-memory `KhiveConfig` from the legacy env-var path.
617///
618/// Used when no config file is present. Emits `tracing::info!` directing
619/// operators to migrate to `~/.khive/config.toml`.
620///
621/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
622/// engine; additional models become non-default secondary engines.
623pub fn config_from_env() -> KhiveConfig {
624    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
625        .ok()
626        .filter(|s| !s.trim().is_empty());
627    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
628        .ok()
629        .unwrap_or_default();
630    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
631        .into_iter()
632        .filter(|s| !s.is_empty())
633        .collect();
634
635    if primary_model.is_none() && additional.is_empty() {
636        return KhiveConfig::default();
637    }
638
639    tracing::info!(
640        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
641    );
642
643    let mut engines = Vec::new();
644
645    if let Some(model) = primary_model {
646        engines.push(EngineConfig {
647            name: "default".to_string(),
648            model,
649            default: true,
650            fusion_weight: None,
651            dims: None,
652        });
653    }
654
655    for (i, model) in additional.into_iter().enumerate() {
656        engines.push(EngineConfig {
657            name: format!("engine-{}", i + 1),
658            model,
659            default: false,
660            fusion_weight: None,
661            dims: None,
662        });
663    }
664
665    // If no primary was specified but there are additional models, promote the
666    // first additional model as the default so the list stays valid.
667    if !engines.is_empty() && !engines.iter().any(|e| e.default) {
668        engines[0].default = true;
669    }
670
671    KhiveConfig {
672        engines,
673        ..KhiveConfig::default()
674    }
675}
676
677// ---- Tests ----
678
679// Kept inline (not tests/): exercises private ConfigError variants not part
680// of the public API.
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
686        let path = dir.path().join("config.toml");
687        std::fs::write(&path, content).unwrap();
688        path
689    }
690
691    #[test]
692    fn test_load_minimal_config() {
693        let dir = tempfile::tempdir().unwrap();
694        let path = write_toml(
695            &dir,
696            r#"
697[[engines]]
698name = "x"
699model = "all-minilm-l6-v2"
700default = true
701"#,
702        );
703        let cfg = KhiveConfig::load(Some(&path))
704            .expect("load should succeed")
705            .expect("file should be found");
706        assert_eq!(cfg.engines.len(), 1);
707        assert_eq!(cfg.engines[0].name, "x");
708        assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
709        assert!(cfg.engines[0].default);
710    }
711
712    #[test]
713    fn test_default_engine_required_when_engines_present() {
714        let dir = tempfile::tempdir().unwrap();
715        let path = write_toml(
716            &dir,
717            r#"
718[[engines]]
719name = "a"
720model = "all-minilm-l6-v2"
721"#,
722        );
723        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
724        assert!(
725            matches!(err, ConfigError::DefaultCount { found: 0 }),
726            "expected DefaultCount {{ found: 0 }}, got {err:?}"
727        );
728    }
729
730    #[test]
731    fn test_multiple_default_rejected() {
732        let dir = tempfile::tempdir().unwrap();
733        let path = write_toml(
734            &dir,
735            r#"
736[[engines]]
737name = "a"
738model = "all-minilm-l6-v2"
739default = true
740
741[[engines]]
742name = "b"
743model = "paraphrase-multilingual-minilm-l12-v2"
744default = true
745"#,
746        );
747        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
748        assert!(
749            matches!(err, ConfigError::DefaultCount { found: 2 }),
750            "expected DefaultCount {{ found: 2 }}, got {err:?}"
751        );
752    }
753
754    #[test]
755    fn test_fusion_weight_validation() {
756        let dir = tempfile::tempdir().unwrap();
757        let path = write_toml(
758            &dir,
759            r#"
760[[engines]]
761name = "a"
762model = "all-minilm-l6-v2"
763default = true
764fusion_weight = -0.5
765"#,
766        );
767        let err =
768            KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
769        assert!(
770            matches!(err, ConfigError::InvalidFusionWeight { .. }),
771            "expected InvalidFusionWeight, got {err:?}"
772        );
773
774        let path2 = write_toml(
775            &dir,
776            r#"
777[[engines]]
778name = "a"
779model = "all-minilm-l6-v2"
780default = true
781fusion_weight = 0.0
782"#,
783        );
784        let err2 =
785            KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
786        assert!(
787            matches!(err2, ConfigError::InvalidFusionWeight { .. }),
788            "expected InvalidFusionWeight, got {err2:?}"
789        );
790    }
791
792    #[test]
793    fn test_env_var_fallback() {
794        let dir = tempfile::tempdir().unwrap();
795        let absent = dir.path().join("missing.toml");
796
797        let loaded = KhiveConfig::load(Some(&absent)).unwrap();
798        assert!(loaded.is_none());
799
800        // Can't safely set env vars in a parallel test suite, so exercise the
801        // direct construction path instead.
802        let primary = "all-minilm-l6-v2".to_string();
803        let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
804
805        let mut engines = vec![EngineConfig {
806            name: "default".to_string(),
807            model: primary,
808            default: true,
809            fusion_weight: None,
810            dims: None,
811        }];
812        for (i, model) in additional.into_iter().enumerate() {
813            engines.push(EngineConfig {
814                name: format!("engine-{}", i + 1),
815                model,
816                default: false,
817                fusion_weight: None,
818                dims: None,
819            });
820        }
821        let cfg = KhiveConfig {
822            engines,
823            ..KhiveConfig::default()
824        };
825        cfg.validate().expect("env-derived config should be valid");
826        assert_eq!(cfg.engines.len(), 2);
827        assert!(cfg.default_engine().is_some());
828        assert_eq!(cfg.default_engine().unwrap().name, "default");
829    }
830
831    #[test]
832    fn test_file_overrides_env() {
833        let dir = tempfile::tempdir().unwrap();
834        let path = write_toml(
835            &dir,
836            r#"
837[[engines]]
838name = "file-engine"
839model = "all-minilm-l6-v2"
840default = true
841"#,
842        );
843
844        // KhiveConfig::load returns the file config regardless of env vars;
845        // warning-on-conflict is the caller's responsibility.
846        let cfg = KhiveConfig::load(Some(&path))
847            .expect("load should succeed")
848            .expect("file should be present");
849        assert_eq!(cfg.engines[0].name, "file-engine");
850    }
851
852    #[test]
853    fn test_duplicate_engine_names_rejected() {
854        let dir = tempfile::tempdir().unwrap();
855        let path = write_toml(
856            &dir,
857            r#"
858[[engines]]
859name = "shared"
860model = "all-minilm-l6-v2"
861default = true
862
863[[engines]]
864name = "shared"
865model = "paraphrase-multilingual-minilm-l12-v2"
866"#,
867        );
868        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
869        assert!(
870            matches!(err, ConfigError::DuplicateName { .. }),
871            "expected DuplicateName, got {err:?}"
872        );
873    }
874
875    #[test]
876    fn test_empty_config_is_valid() {
877        let dir = tempfile::tempdir().unwrap();
878        let path = write_toml(&dir, "# no engines\n");
879        let cfg = KhiveConfig::load(Some(&path))
880            .expect("load should succeed")
881            .expect("file should be found");
882        assert!(cfg.engines.is_empty());
883        cfg.validate().expect("empty config should be valid");
884    }
885
886    #[test]
887    fn test_multi_engine_positive_fusion_weight() {
888        let dir = tempfile::tempdir().unwrap();
889        let path = write_toml(
890            &dir,
891            r#"
892[[engines]]
893name = "primary"
894model = "all-minilm-l6-v2"
895default = true
896fusion_weight = 0.7
897
898[[engines]]
899name = "secondary"
900model = "paraphrase-multilingual-minilm-l12-v2"
901fusion_weight = 0.3
902"#,
903        );
904        let cfg = KhiveConfig::load(Some(&path))
905            .expect("load should succeed")
906            .expect("file should be found");
907        assert_eq!(cfg.engines.len(), 2);
908        assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
909        assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
910    }
911
912    #[test]
913    fn test_actor_id_parsed() {
914        let dir = tempfile::tempdir().unwrap();
915        let path = write_toml(
916            &dir,
917            r#"
918[actor]
919id = "lambda:khive"
920display_name = "example actor"
921"#,
922        );
923        let cfg = KhiveConfig::load(Some(&path))
924            .expect("load should succeed")
925            .expect("file should be found");
926        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
927        assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
928        assert!(cfg.engines.is_empty());
929    }
930
931    #[test]
932    fn test_actor_and_engines_together() {
933        let dir = tempfile::tempdir().unwrap();
934        let path = write_toml(
935            &dir,
936            r#"
937[actor]
938id = "lambda:test"
939
940[[engines]]
941name = "default"
942model = "all-minilm-l6-v2"
943default = true
944"#,
945        );
946        let cfg = KhiveConfig::load(Some(&path))
947            .expect("load should succeed")
948            .expect("file should be found");
949        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
950        assert_eq!(cfg.engines.len(), 1);
951    }
952
953    #[test]
954    fn test_actor_absent_defaults_to_none() {
955        let dir = tempfile::tempdir().unwrap();
956        let path = write_toml(
957            &dir,
958            r#"
959[[engines]]
960name = "x"
961model = "all-minilm-l6-v2"
962default = true
963"#,
964        );
965        let cfg = KhiveConfig::load(Some(&path))
966            .expect("load should succeed")
967            .expect("file should be found");
968        assert!(
969            cfg.actor.id.is_none(),
970            "actor.id must be None when [actor] section is absent"
971        );
972    }
973
974    #[test]
975    fn test_load_with_home_fallback_no_files() {
976        let project_dir = tempfile::tempdir().unwrap();
977        let home_dir = tempfile::tempdir().unwrap();
978        let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
979        assert!(
980            result.expect("no error expected").is_none(),
981            "should return None when no config files exist in the given roots"
982        );
983    }
984
985    #[test]
986    fn test_load_with_home_fallback_explicit_path() {
987        let dir = tempfile::tempdir().unwrap();
988        let path = write_toml(
989            &dir,
990            r#"
991[actor]
992id = "lambda:explicit"
993"#,
994        );
995        let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
996            .expect("no error expected")
997            .expect("file found");
998        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
999    }
1000
1001    #[test]
1002    fn test_invalid_actor_id_rejected_at_load() {
1003        let dir = tempfile::tempdir().unwrap();
1004        let path = write_toml(
1005            &dir,
1006            r#"
1007[actor]
1008id = "bad namespace"
1009"#,
1010        );
1011        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
1012        assert!(
1013            matches!(err, ConfigError::InvalidActorId { .. }),
1014            "expected InvalidActorId, got {err:?}"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_empty_actor_id_rejected() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let path = write_toml(
1022            &dir,
1023            r#"
1024[actor]
1025id = ""
1026"#,
1027        );
1028        let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
1029        assert!(
1030            matches!(err, ConfigError::InvalidActorId { .. }),
1031            "expected InvalidActorId for empty string, got {err:?}"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_malformed_actor_id_lambda_colon_only() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let path = write_toml(
1039            &dir,
1040            r#"
1041[actor]
1042id = "lambda:"
1043"#,
1044        );
1045        let err =
1046            KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
1047        assert!(
1048            matches!(err, ConfigError::InvalidActorId { .. }),
1049            "expected InvalidActorId for 'lambda:', got {err:?}"
1050        );
1051    }
1052
1053    // actor.id must not become default_namespace: writes stay pinned to `local`
1054    // even though a non-local actor.id widens the default read visible-set.
1055    #[test]
1056    fn test_runtime_config_actor_id_does_not_override_namespace() {
1057        use crate::runtime::runtime_config_from_khive_config;
1058        use crate::RuntimeConfig;
1059        use khive_types::namespace::Namespace;
1060
1061        let cfg = KhiveConfig {
1062            engines: vec![],
1063            actor: ActorConfig {
1064                id: Some("lambda:test-actor".to_string()),
1065                display_name: None,
1066                ..Default::default()
1067            },
1068            ..KhiveConfig::default()
1069        };
1070        cfg.validate().expect("valid config");
1071
1072        let base = RuntimeConfig::default();
1073        let result = runtime_config_from_khive_config(&cfg, base);
1074        assert_eq!(
1075            result.default_namespace,
1076            Namespace::local(),
1077            "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
1078             writes stay pinned to local"
1079        );
1080        // actor.id must also appear in visible_namespaces: the load-bearing
1081        // side effect that widens default reads to {local} ∪ {actor namespace}.
1082        assert!(
1083            result
1084                .visible_namespaces
1085                .contains(&Namespace::parse("lambda:test-actor").unwrap()),
1086            "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
1087             got: {:?}",
1088            result.visible_namespaces
1089        );
1090    }
1091
1092    #[test]
1093    fn test_runtime_config_no_actor_preserves_base() {
1094        use crate::runtime::runtime_config_from_khive_config;
1095        use crate::RuntimeConfig;
1096        use khive_types::namespace::Namespace;
1097
1098        let cfg = KhiveConfig {
1099            engines: vec![],
1100            actor: ActorConfig {
1101                id: None,
1102                display_name: None,
1103                ..Default::default()
1104            },
1105            ..KhiveConfig::default()
1106        };
1107        cfg.validate().expect("valid config");
1108
1109        let base_ns = Namespace::parse("lambda:base").unwrap();
1110        let base = RuntimeConfig {
1111            default_namespace: base_ns.clone(),
1112            ..RuntimeConfig::default()
1113        };
1114        let result = runtime_config_from_khive_config(&cfg, base);
1115        assert_eq!(
1116            result.default_namespace, base_ns,
1117            "no actor.id must leave base namespace unchanged"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_load_with_home_fallback_project_root_over_hidden() {
1123        let dir = tempfile::tempdir().unwrap();
1124
1125        // Write .khive/config.toml (tier 3).
1126        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1127        std::fs::write(
1128            dir.path().join(".khive/config.toml"),
1129            "[actor]\nid = \"lambda:hidden\"\n",
1130        )
1131        .unwrap();
1132
1133        // Write khive.toml (tier 2) — should win.
1134        std::fs::write(
1135            dir.path().join("khive.toml"),
1136            "[actor]\nid = \"lambda:project-root\"\n",
1137        )
1138        .unwrap();
1139
1140        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1141            .expect("no error expected")
1142            .expect("file should be found");
1143        assert_eq!(
1144            cfg.actor.id.as_deref(),
1145            Some("lambda:project-root"),
1146            "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_load_with_home_fallback_hidden_over_absent_root() {
1152        let dir = tempfile::tempdir().unwrap();
1153
1154        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
1155        std::fs::write(
1156            dir.path().join(".khive/config.toml"),
1157            "[actor]\nid = \"lambda:hidden-config\"\n",
1158        )
1159        .unwrap();
1160        // No khive.toml.
1161
1162        let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
1163            .expect("no error expected")
1164            .expect("file should be found");
1165        assert_eq!(
1166            cfg.actor.id.as_deref(),
1167            Some("lambda:hidden-config"),
1168            ".khive/config.toml (tier 3) must be found when khive.toml is absent"
1169        );
1170    }
1171
1172    #[test]
1173    fn test_load_with_roots_home_tier_found() {
1174        let project_dir = tempfile::tempdir().unwrap();
1175        let home_dir = tempfile::tempdir().unwrap();
1176
1177        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1178        std::fs::write(
1179            home_dir.path().join(".khive/config.toml"),
1180            "[actor]\nid = \"lambda:user-global\"\n",
1181        )
1182        .unwrap();
1183        // No project-level files.
1184
1185        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1186            .expect("no error expected")
1187            .expect("file should be found");
1188        assert_eq!(
1189            cfg.actor.id.as_deref(),
1190            Some("lambda:user-global"),
1191            "~/.khive/config.toml (tier 4) must be found when project files absent"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_load_with_roots_project_wins_over_home() {
1197        let project_dir = tempfile::tempdir().unwrap();
1198        let home_dir = tempfile::tempdir().unwrap();
1199
1200        // Home has a config.
1201        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1202        std::fs::write(
1203            home_dir.path().join(".khive/config.toml"),
1204            "[actor]\nid = \"lambda:user-global\"\n",
1205        )
1206        .unwrap();
1207
1208        // Project also has a config — should win.
1209        std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
1210        std::fs::write(
1211            project_dir.path().join(".khive/config.toml"),
1212            "[actor]\nid = \"lambda:project-wins\"\n",
1213        )
1214        .unwrap();
1215
1216        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
1217            .expect("no error expected")
1218            .expect("file should be found");
1219        assert_eq!(
1220            cfg.actor.id.as_deref(),
1221            Some("lambda:project-wins"),
1222            "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
1223        );
1224    }
1225
1226    // ── tier-3 db-dir anchor tests (config discovery canonicalization) ─────
1227
1228    // Two different process working directories, targeting the same database,
1229    // must resolve the identical tier-3 config file. Each cwd also carries its
1230    // own decoy `.khive/config.toml` so the test fails loudly (mismatched
1231    // actor ids) if the resolver ever falls back to the old cwd anchor instead
1232    // of the db-dir anchor.
1233    #[test]
1234    fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
1235        let cwd_a = tempfile::tempdir().unwrap();
1236        let cwd_b = tempfile::tempdir().unwrap();
1237
1238        // Decoy cwd-anchored configs — must NOT be picked up once anchoring
1239        // moves to the db directory.
1240        std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
1241        std::fs::write(
1242            cwd_a.path().join(".khive/config.toml"),
1243            "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
1244        )
1245        .unwrap();
1246        std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
1247        std::fs::write(
1248            cwd_b.path().join(".khive/config.toml"),
1249            "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
1250        )
1251        .unwrap();
1252
1253        // The database and its co-located config live under a THIRD root,
1254        // distinct from either simulated cwd.
1255        let db_root = tempfile::tempdir().unwrap();
1256        let khive_dir = db_root.path().join(".khive");
1257        std::fs::create_dir_all(&khive_dir).unwrap();
1258        let db_path = khive_dir.join("khive.db");
1259        std::fs::write(&db_path, b"").unwrap(); // must exist for canonicalize to succeed
1260        std::fs::write(
1261            khive_dir.join("config.toml"),
1262            "[actor]\nid = \"lambda:db-anchored\"\n",
1263        )
1264        .unwrap();
1265
1266        let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
1267            .expect("no error expected")
1268            .expect("db-anchored config must be found from cwd A");
1269        let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
1270            .expect("no error expected")
1271            .expect("db-anchored config must be found from cwd B");
1272
1273        assert_eq!(
1274            cfg_a.actor.id.as_deref(),
1275            Some("lambda:db-anchored"),
1276            "cwd A must resolve the db-anchored config, not its own decoy"
1277        );
1278        assert_eq!(
1279            cfg_b.actor.id.as_deref(),
1280            Some("lambda:db-anchored"),
1281            "cwd B must resolve the db-anchored config, not its own decoy"
1282        );
1283        assert_eq!(
1284            cfg_a.actor.id, cfg_b.actor.id,
1285            "two processes at different cwds targeting the same db must resolve \
1286             identical config, killing config_id drift between client and daemon"
1287        );
1288    }
1289
1290    // Explicit `--config`/`KHIVE_CONFIG` (tier 1) must still win over the new
1291    // db-dir anchor (tier 3) — precedence is preserved, only the tier-3 anchor
1292    // moved.
1293    #[test]
1294    fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
1295        let explicit_dir = tempfile::tempdir().unwrap();
1296        let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
1297
1298        let db_root = tempfile::tempdir().unwrap();
1299        let khive_dir = db_root.path().join(".khive");
1300        std::fs::create_dir_all(&khive_dir).unwrap();
1301        let db_path = khive_dir.join("khive.db");
1302        std::fs::write(&db_path, b"").unwrap();
1303        std::fs::write(
1304            khive_dir.join("config.toml"),
1305            "[actor]\nid = \"lambda:db-anchor-loses\"\n",
1306        )
1307        .unwrap();
1308
1309        let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
1310            .expect("no error expected")
1311            .expect("explicit path must be found");
1312        assert_eq!(
1313            cfg.actor.id.as_deref(),
1314            Some("lambda:explicit-wins"),
1315            "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
1316        );
1317    }
1318
1319    // Tier 4 (`~/.khive/config.toml`) must still be reached when the db-anchored
1320    // tier-3 directory has no `config.toml` alongside it.
1321    #[test]
1322    fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
1323        let cwd = tempfile::tempdir().unwrap();
1324        let home_dir = tempfile::tempdir().unwrap();
1325        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1326        std::fs::write(
1327            home_dir.path().join(".khive/config.toml"),
1328            "[actor]\nid = \"lambda:home-fallback\"\n",
1329        )
1330        .unwrap();
1331
1332        // A real db directory that exists but has no co-located config.toml.
1333        let db_root = tempfile::tempdir().unwrap();
1334        let khive_dir = db_root.path().join(".khive");
1335        std::fs::create_dir_all(&khive_dir).unwrap();
1336        let db_path = khive_dir.join("khive.db");
1337        std::fs::write(&db_path, b"").unwrap();
1338
1339        let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
1340            .expect("no error expected")
1341            .expect("home-tier config must be found");
1342        assert_eq!(
1343            cfg.actor.id.as_deref(),
1344            Some("lambda:home-fallback"),
1345            "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
1346             tier-3 directory has no config.toml"
1347        );
1348    }
1349
1350    // Cold start: the database file does not exist yet (first run). Anchor
1351    // resolution must not panic and must fall through the remaining tiers.
1352    #[test]
1353    fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
1354        let cwd = tempfile::tempdir().unwrap();
1355        let home_dir = tempfile::tempdir().unwrap();
1356        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
1357        std::fs::write(
1358            home_dir.path().join(".khive/config.toml"),
1359            "[actor]\nid = \"lambda:home-cold-start\"\n",
1360        )
1361        .unwrap();
1362
1363        // Absolute path under a directory tree that was never created.
1364        let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
1365
1366        let cfg =
1367            KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
1368                .expect("cold-start db path must not error or panic")
1369                .expect("home-tier config must still be found");
1370        assert_eq!(
1371            cfg.actor.id.as_deref(),
1372            Some("lambda:home-cold-start"),
1373            "a nonexistent db path (cold start) must fall through to tier 4, not panic"
1374        );
1375    }
1376
1377    // Cold start with a *relative* nonexistent db path exercises the
1378    // cwd-join fallback branch specifically (as opposed to the
1379    // already-absolute fallback branch above). Must not panic; no config
1380    // exists anywhere so the result is `Ok(None)`.
1381    #[test]
1382    fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
1383        let cwd = tempfile::tempdir().unwrap();
1384        let relative_db = PathBuf::from("never-created/.khive/khive.db");
1385
1386        let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
1387        assert!(
1388            result.is_ok(),
1389            "relative cold-start db path must not error or panic: {result:?}"
1390        );
1391        assert!(
1392            result.unwrap().is_none(),
1393            "no config exists anywhere in this test; result must be None"
1394        );
1395    }
1396
1397    // ── ADR-028 backend / pack config tests ─────────────────────────────────
1398
1399    #[test]
1400    fn test_no_backends_section_is_valid() {
1401        let dir = tempfile::tempdir().unwrap();
1402        let path = write_toml(
1403            &dir,
1404            r#"
1405[[engines]]
1406name = "default"
1407model = "all-minilm-l6-v2"
1408default = true
1409"#,
1410        );
1411        let cfg = KhiveConfig::load(Some(&path))
1412            .expect("no error")
1413            .expect("file found");
1414        assert!(cfg.backends.is_empty());
1415        assert!(cfg.packs.is_empty());
1416    }
1417
1418    #[test]
1419    fn test_single_sqlite_backend_parses() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let path = write_toml(
1422            &dir,
1423            r#"
1424[[backends]]
1425name = "knowledge"
1426kind = "sqlite"
1427path = "/tmp/knowledge.db"
1428"#,
1429        );
1430        let cfg = KhiveConfig::load(Some(&path))
1431            .expect("no error")
1432            .expect("file found");
1433        assert_eq!(cfg.backends.len(), 1);
1434        let b = &cfg.backends[0];
1435        assert_eq!(b.name, "knowledge");
1436        assert!(matches!(b.kind, BackendKind::Sqlite));
1437        assert_eq!(
1438            b.path.as_ref().and_then(|p| p.to_str()),
1439            Some("/tmp/knowledge.db")
1440        );
1441    }
1442
1443    #[test]
1444    fn test_memory_backend_parses() {
1445        let dir = tempfile::tempdir().unwrap();
1446        let path = write_toml(
1447            &dir,
1448            r#"
1449[[backends]]
1450name = "ephemeral"
1451kind = "memory"
1452"#,
1453        );
1454        let cfg = KhiveConfig::load(Some(&path))
1455            .expect("no error")
1456            .expect("file found");
1457        assert_eq!(cfg.backends.len(), 1);
1458        assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
1459    }
1460
1461    #[test]
1462    fn test_pack_backend_assignment_parses() {
1463        let dir = tempfile::tempdir().unwrap();
1464        let path = write_toml(
1465            &dir,
1466            r#"
1467[[backends]]
1468name = "knowledge"
1469kind = "memory"
1470
1471[packs.knowledge]
1472backend = "knowledge"
1473"#,
1474        );
1475        let cfg = KhiveConfig::load(Some(&path))
1476            .expect("no error")
1477            .expect("file found");
1478        assert_eq!(cfg.packs.len(), 1);
1479        let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
1480        assert_eq!(pc.backend, "knowledge");
1481    }
1482
1483    #[test]
1484    fn test_duplicate_backend_name_rejected() {
1485        let dir = tempfile::tempdir().unwrap();
1486        let path = write_toml(
1487            &dir,
1488            r#"
1489[[backends]]
1490name = "dup"
1491kind = "memory"
1492
1493[[backends]]
1494name = "dup"
1495kind = "memory"
1496"#,
1497        );
1498        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
1499        assert!(
1500            matches!(err, ConfigError::DuplicateBackendName { ref name } if name == "dup"),
1501            "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_pack_referencing_undefined_backend_rejected() {
1507        let dir = tempfile::tempdir().unwrap();
1508        let path = write_toml(
1509            &dir,
1510            r#"
1511[[backends]]
1512name = "knowledge"
1513kind = "memory"
1514
1515[packs.kg]
1516backend = "nonexistent"
1517"#,
1518        );
1519        let err =
1520            KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
1521        assert!(
1522            matches!(err, ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
1523                if pack == "kg" && backend == "nonexistent"),
1524            "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_pack_config_without_backends_section_is_allowed() {
1530        let dir = tempfile::tempdir().unwrap();
1531        // When [[backends]] is absent/empty, packs are not validated: all
1532        // packs fall through to the implicit main backend.
1533        let path = write_toml(
1534            &dir,
1535            r#"
1536[packs.kg]
1537backend = "main"
1538"#,
1539        );
1540        let cfg = KhiveConfig::load(Some(&path))
1541            .expect("no error expected")
1542            .expect("file found");
1543        assert_eq!(cfg.backends.len(), 0);
1544        assert_eq!(cfg.packs.len(), 1);
1545    }
1546
1547    #[test]
1548    fn test_backend_cache_mb_rejected_at_validate() {
1549        let dir = tempfile::tempdir().unwrap();
1550        let path = write_toml(
1551            &dir,
1552            r#"
1553[[backends]]
1554name = "main"
1555kind = "memory"
1556cache_mb = 128
1557"#,
1558        );
1559        let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
1560        assert!(
1561            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
1562            "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_backend_journal_mode_rejected_at_validate() {
1568        let dir = tempfile::tempdir().unwrap();
1569        let path = write_toml(
1570            &dir,
1571            r#"
1572[[backends]]
1573name = "main"
1574kind = "memory"
1575journal_mode = "wal"
1576"#,
1577        );
1578        let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
1579        assert!(
1580            matches!(err, ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
1581            "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
1582        );
1583    }
1584
1585    // A top-level `db` key must be rejected loudly instead of silently
1586    // ignored as an unknown key by serde's forward-compatible default.
1587    #[test]
1588    fn test_top_level_db_rejected_at_validate() {
1589        let dir = tempfile::tempdir().unwrap();
1590        let path = write_toml(
1591            &dir,
1592            r#"
1593db = "/tmp/scratch/demo.db"
1594"#,
1595        );
1596        let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
1597        assert!(
1598            matches!(err, ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
1599            "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
1600        );
1601    }
1602}