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
13// ---- Error type ----
14
15/// Errors produced while loading or validating a `KhiveConfig`.
16#[derive(Debug, Error)]
17pub enum ConfigError {
18    #[error("config file I/O: {0}")]
19    Io(#[from] std::io::Error),
20
21    #[error("config TOML parse error in {path}: {source}")]
22    Parse {
23        path: PathBuf,
24        #[source]
25        source: toml::de::Error,
26    },
27
28    #[error("exactly one engine must be marked `default = true`; found {found}")]
29    DefaultCount { found: usize },
30
31    #[error("duplicate engine name: {name:?}")]
32    DuplicateName { name: String },
33
34    #[error(
35        "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
36    )]
37    UnknownModel { name: String, model: String },
38
39    #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
40    InvalidFusionWeight { name: String, value: f64 },
41
42    #[error("actor.id {id:?} is not a valid namespace: {reason}")]
43    InvalidActorId { id: String, reason: String },
44}
45
46// ---- Config structs ----
47
48/// Configuration for a single embedding engine.
49#[derive(Debug, Clone, Deserialize)]
50pub struct EngineConfig {
51    /// Logical name used to reference this engine in logs and fusion.
52    pub name: String,
53
54    /// Lattice-embed model name (e.g. `"all-minilm-l6-v2"`).
55    ///
56    /// Must be parseable via `lattice_embed::EmbeddingModel::from_str` (or a
57    /// recognised short alias handled by `parse_embedding_model_alias`).
58    pub model: String,
59
60    /// When `true`, this engine's model becomes the primary (`RuntimeConfig::embedding_model`).
61    /// Exactly one engine in the list must set this. If absent, defaults to `false`.
62    #[serde(default)]
63    pub default: bool,
64
65    /// RRF fusion weight for weighted multi-engine fusion.
66    ///
67    /// Only meaningful when multiple engines are loaded. Must be `> 0` when
68    /// present. `None` means the engine participates in fusion with equal weight
69    /// to other engines that also lack a `fusion_weight`.
70    ///
71    /// For RRF: `fusion_weight` provides per-engine relative importance during
72    /// weighted RRF; it does NOT apply to rank-based unweighted RRF (the weights
73    /// are injected into `FusionStrategy::Weighted` only).
74    pub fusion_weight: Option<f64>,
75
76    /// Expected output dimensionality (optional sanity check).
77    ///
78    /// Not used at runtime — dimensions are authoritative from
79    /// `EmbeddingModel::dimensions()`. Present so operators can document the
80    /// expected shape alongside the model name.
81    pub dims: Option<u32>,
82}
83
84/// Actor configuration — the default namespace / identity for this khive instance.
85///
86/// Corresponds to the `[actor]` TOML section. In OSS mode the runtime uses
87/// `id` as the `default_namespace` stamped on every write operation. Cloud
88/// deployments derive the namespace from an authenticated `NamespaceToken`
89/// instead; the `[actor]` section is ignored there.
90///
91/// ```toml
92/// [actor]
93/// id = "lambda:khive"          # default namespace (required)
94/// display_name = "Ocean's khive lambda"  # human label (optional)
95/// ```
96#[derive(Debug, Clone, Deserialize, Default)]
97pub struct ActorConfig {
98    /// Namespace identifier used as the default actor for all operations.
99    ///
100    /// Must be a valid `Namespace` string (e.g. `"local"`, `"lambda:khive"`).
101    /// Defaults to `"local"` when absent — backward-compatible with pre-actor
102    /// deployments.
103    #[serde(default)]
104    pub id: Option<String>,
105
106    /// Optional human-readable label for this actor. Not used by the runtime;
107    /// surfaced in introspection and log output only.
108    #[serde(default)]
109    pub display_name: Option<String>,
110}
111
112/// Top-level khive configuration loaded from `khive.toml` or `config.toml`.
113///
114/// Sections consumed today:
115/// - `[[engines]]`: embedding engine declarations
116/// - `[actor]`: default namespace / identity (OSS actor model)
117/// - `[runtime]`: runtime knobs (namespace, brain_profile)
118///
119/// Unknown keys are silently ignored by serde — forward-compatible.
120#[derive(Debug, Clone, Deserialize, Default)]
121pub struct KhiveConfig {
122    /// Embedding engine declarations.
123    #[serde(default)]
124    pub engines: Vec<EngineConfig>,
125
126    /// Default actor (namespace) for this khive instance.
127    ///
128    /// When present, `actor.id` becomes the `default_namespace` used by the
129    /// runtime when no per-operation `namespace` argument is supplied. OSS
130    /// model: no enforcement — any operation may still pass `namespace=` to
131    /// use a different namespace. Cloud model derives namespace from an
132    /// authenticated token and ignores this field.
133    #[serde(default)]
134    pub actor: ActorConfig,
135
136    /// Runtime knobs: namespace overrides, brain profile, etc.
137    #[serde(default)]
138    pub runtime: RuntimeSectionConfig,
139}
140
141/// `[runtime]` section in `khive.toml`.
142///
143/// Carries runtime knobs that mirror the CLI flag / env var tier.
144/// All fields are optional; absent keys fall through to env vars or built-in
145/// defaults.
146#[derive(Debug, Clone, Deserialize, Default)]
147pub struct RuntimeSectionConfig {
148    /// Brain profile ID to use for `memory.feedback` / `knowledge.feedback`
149    /// and recall-time score boosting (ADR-035 §Brain profile configuration).
150    ///
151    /// Mirrors `--brain-profile` / `KHIVE_BRAIN_PROFILE`. When absent, the
152    /// namespace-bound profile (via `brain.resolve`) is tried, then the
153    /// global tuning prior is used as the final fallback.
154    #[serde(default)]
155    pub brain_profile: Option<String>,
156}
157
158impl KhiveConfig {
159    /// Load and validate a `KhiveConfig` from an explicit path.
160    ///
161    /// Search order:
162    /// 1. `path` argument (explicit override — e.g. from `--config` / `KHIVE_CONFIG`)
163    /// 2. `./.khive/config.toml` (project-local config, relative to the MCP server cwd)
164    ///
165    /// The project-local default collocates config with the `khive-test.db` that already
166    /// lives under `.khive/` in each project directory. `~/.khive/config.toml` is searched
167    /// by [`KhiveConfig::load_with_home_fallback`] when the project-local file is absent.
168    ///
169    /// If the resolved file does **not exist**, returns `Ok(None)`.
170    /// A missing config is not an error — callers fall back to the env-var path.
171    ///
172    /// If the file exists but cannot be parsed, returns a `ConfigError`.
173    /// After parsing, `validate()` runs and any logical errors are returned.
174    pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
175        let resolved = match path {
176            Some(p) => p.to_path_buf(),
177            None => PathBuf::from(".khive/config.toml"),
178        };
179
180        if !resolved.exists() {
181            return Ok(None);
182        }
183
184        let raw = std::fs::read_to_string(&resolved)?;
185        let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
186            path: resolved,
187            source,
188        })?;
189        cfg.validate()?;
190        Ok(Some(cfg))
191    }
192
193    /// Load config with the full resolution order:
194    ///
195    /// 1. Explicit `path` (from `--config` / `KHIVE_CONFIG`)
196    /// 2. `./khive.toml` (project-local, project root)
197    /// 3. `./.khive/config.toml` (project-local, hidden dir)
198    /// 4. `~/.khive/config.toml` (user-global)
199    ///
200    /// Returns the first file found, or `Ok(None)` when none exist.
201    /// Parse errors are propagated immediately — a malformed config is always
202    /// an error regardless of which tier it came from.
203    pub fn load_with_home_fallback(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
204        // Tier 1: explicit path (highest priority).
205        if let Some(p) = path {
206            return Self::load(Some(p));
207        }
208
209        // Tiers 2-4: search project root, hidden dir, user-global.
210        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
211        let home_root = std::env::var_os("HOME").map(PathBuf::from);
212        Self::load_with_roots(&project_root, home_root.as_deref())
213    }
214
215    /// Testable inner search: tiers 2-4, given explicit roots instead of
216    /// reading `cwd` and `HOME` from process state.
217    ///
218    /// - Tier 2: `<project_root>/khive.toml`
219    /// - Tier 3: `<project_root>/.khive/config.toml`
220    /// - Tier 4: `<home_root>/.khive/config.toml` (skipped when `None`)
221    pub(crate) fn load_with_roots(
222        project_root: &Path,
223        home_root: Option<&Path>,
224    ) -> Result<Option<Self>, ConfigError> {
225        // Tier 2: project root khive.toml.
226        let tier2 = project_root.join("khive.toml");
227        if tier2.exists() {
228            return Self::load(Some(&tier2));
229        }
230
231        // Tier 3: project-local hidden dir.
232        let tier3 = project_root.join(".khive/config.toml");
233        if tier3.exists() {
234            return Self::load(Some(&tier3));
235        }
236
237        // Tier 4: user-global ~/.khive/config.toml.
238        if let Some(home) = home_root {
239            let tier4 = home.join(".khive/config.toml");
240            if tier4.exists() {
241                return Self::load(Some(&tier4));
242            }
243        }
244
245        Ok(None)
246    }
247
248    /// Validate the parsed config for logical consistency.
249    ///
250    /// Checks:
251    /// - Exactly one engine has `default = true` (when the list is non-empty).
252    /// - Engine names are unique.
253    /// - `fusion_weight`, when present, is `> 0`.
254    ///
255    /// Model name validity is checked lazily at runtime (the config loader does
256    /// not import `lattice_embed` directly to keep the dep surface minimal).
257    pub fn validate(&self) -> Result<(), ConfigError> {
258        // Validate actor.id when present — an invalid namespace is a startup error,
259        // not a silent fallback.
260        if let Some(id) = self.actor.id.as_deref() {
261            if id.is_empty() {
262                return Err(ConfigError::InvalidActorId {
263                    id: id.to_string(),
264                    reason: "actor.id must not be empty; remove the key or provide a value"
265                        .to_string(),
266                });
267            }
268            Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
269                id: id.to_string(),
270                reason: e.to_string(),
271            })?;
272        }
273
274        if self.engines.is_empty() {
275            return Ok(());
276        }
277
278        // Unique names
279        let mut seen_names = std::collections::HashSet::new();
280        for engine in &self.engines {
281            if !seen_names.insert(engine.name.clone()) {
282                return Err(ConfigError::DuplicateName {
283                    name: engine.name.clone(),
284                });
285            }
286        }
287
288        // Exactly one default
289        let default_count = self.engines.iter().filter(|e| e.default).count();
290        if default_count != 1 {
291            return Err(ConfigError::DefaultCount {
292                found: default_count,
293            });
294        }
295
296        // Positive, finite fusion_weight when present.
297        // NaN does not satisfy `w <= 0.0`, and positive infinity is unbounded,
298        // so reject all non-finite values explicitly before the range check.
299        for engine in &self.engines {
300            if let Some(w) = engine.fusion_weight {
301                if !w.is_finite() || w <= 0.0 {
302                    return Err(ConfigError::InvalidFusionWeight {
303                        name: engine.name.clone(),
304                        value: w,
305                    });
306                }
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Return the engine flagged `default = true`, or `None` if the list is empty.
314    pub fn default_engine(&self) -> Option<&EngineConfig> {
315        self.engines.iter().find(|e| e.default)
316    }
317}
318
319// ---- Env-var fallback ----
320
321/// Build an in-memory `KhiveConfig` from the legacy env-var path.
322///
323/// Used when no config file is present. Emits `tracing::info!` directing
324/// operators to migrate to `~/.khive/config.toml`.
325///
326/// The primary model (`KHIVE_EMBEDDING_MODEL`) becomes the `default = true`
327/// engine; additional models become non-default secondary engines.
328pub fn config_from_env() -> KhiveConfig {
329    let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
330        .ok()
331        .filter(|s| !s.trim().is_empty());
332    let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
333        .ok()
334        .unwrap_or_default();
335    let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
336        .into_iter()
337        .filter(|s| !s.is_empty())
338        .collect();
339
340    if primary_model.is_none() && additional.is_empty() {
341        return KhiveConfig::default();
342    }
343
344    tracing::info!(
345        "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
346    );
347
348    let mut engines = Vec::new();
349
350    if let Some(model) = primary_model {
351        engines.push(EngineConfig {
352            name: "default".to_string(),
353            model,
354            default: true,
355            fusion_weight: None,
356            dims: None,
357        });
358    }
359
360    for (i, model) in additional.into_iter().enumerate() {
361        engines.push(EngineConfig {
362            name: format!("engine-{}", i + 1),
363            model,
364            default: false,
365            fusion_weight: None,
366            dims: None,
367        });
368    }
369
370    // If no primary was specified but there are additional models, promote the
371    // first additional model as the default so the list stays valid.
372    if !engines.is_empty() && !engines.iter().any(|e| e.default) {
373        engines[0].default = true;
374    }
375
376    KhiveConfig {
377        engines,
378        actor: ActorConfig::default(),
379        runtime: RuntimeSectionConfig::default(),
380    }
381}
382
383// ---- Tests ----
384
385// INLINE TEST JUSTIFICATION: tests here cover config validation error paths that
386// rely on private ConfigError variants and temp-file helpers shared with the
387// config loader. Moving them to tests/ would require pub-exporting ConfigError
388// internals that are not part of the stable public API.
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    // Helper: write a temp file and return the path.
394    fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
395        let path = dir.path().join("config.toml");
396        std::fs::write(&path, content).unwrap();
397        path
398    }
399
400    // 1. Minimal config parses successfully.
401    #[test]
402    fn test_load_minimal_config() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = write_toml(
405            &dir,
406            r#"
407[[engines]]
408name = "x"
409model = "all-minilm-l6-v2"
410default = true
411"#,
412        );
413        let cfg = KhiveConfig::load(Some(&path))
414            .expect("load should succeed")
415            .expect("file should be found");
416        assert_eq!(cfg.engines.len(), 1);
417        assert_eq!(cfg.engines[0].name, "x");
418        assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
419        assert!(cfg.engines[0].default);
420    }
421
422    // 2. Zero default-flagged engines -> error.
423    #[test]
424    fn test_default_engine_required_when_engines_present() {
425        let dir = tempfile::tempdir().unwrap();
426        let path = write_toml(
427            &dir,
428            r#"
429[[engines]]
430name = "a"
431model = "all-minilm-l6-v2"
432"#,
433        );
434        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
435        assert!(
436            matches!(err, ConfigError::DefaultCount { found: 0 }),
437            "expected DefaultCount {{ found: 0 }}, got {err:?}"
438        );
439    }
440
441    // 3. Two engines both flagged default -> error.
442    #[test]
443    fn test_multiple_default_rejected() {
444        let dir = tempfile::tempdir().unwrap();
445        let path = write_toml(
446            &dir,
447            r#"
448[[engines]]
449name = "a"
450model = "all-minilm-l6-v2"
451default = true
452
453[[engines]]
454name = "b"
455model = "paraphrase-multilingual-minilm-l12-v2"
456default = true
457"#,
458        );
459        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
460        assert!(
461            matches!(err, ConfigError::DefaultCount { found: 2 }),
462            "expected DefaultCount {{ found: 2 }}, got {err:?}"
463        );
464    }
465
466    // 4. Negative or zero fusion_weight -> error.
467    #[test]
468    fn test_fusion_weight_validation() {
469        let dir = tempfile::tempdir().unwrap();
470        let path = write_toml(
471            &dir,
472            r#"
473[[engines]]
474name = "a"
475model = "all-minilm-l6-v2"
476default = true
477fusion_weight = -0.5
478"#,
479        );
480        let err =
481            KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
482        assert!(
483            matches!(err, ConfigError::InvalidFusionWeight { .. }),
484            "expected InvalidFusionWeight, got {err:?}"
485        );
486
487        let path2 = write_toml(
488            &dir,
489            r#"
490[[engines]]
491name = "a"
492model = "all-minilm-l6-v2"
493default = true
494fusion_weight = 0.0
495"#,
496        );
497        let err2 =
498            KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
499        assert!(
500            matches!(err2, ConfigError::InvalidFusionWeight { .. }),
501            "expected InvalidFusionWeight, got {err2:?}"
502        );
503    }
504
505    // 5. File absent + env vars set -> constructs equivalent KhiveConfig.
506    #[test]
507    fn test_env_var_fallback() {
508        let dir = tempfile::tempdir().unwrap();
509        let absent = dir.path().join("missing.toml");
510
511        // File does not exist -> KhiveConfig::load returns None.
512        let loaded = KhiveConfig::load(Some(&absent)).unwrap();
513        assert!(loaded.is_none());
514
515        // With env vars set, config_from_env builds a synthetic config.
516        // We can't set env vars safely in a parallel test suite, so test via
517        // the direct construction path instead.
518        let primary = "all-minilm-l6-v2".to_string();
519        let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
520
521        let mut engines = vec![EngineConfig {
522            name: "default".to_string(),
523            model: primary,
524            default: true,
525            fusion_weight: None,
526            dims: None,
527        }];
528        for (i, model) in additional.into_iter().enumerate() {
529            engines.push(EngineConfig {
530                name: format!("engine-{}", i + 1),
531                model,
532                default: false,
533                fusion_weight: None,
534                dims: None,
535            });
536        }
537        let cfg = KhiveConfig {
538            engines,
539            actor: ActorConfig::default(),
540            runtime: RuntimeSectionConfig::default(),
541        };
542        cfg.validate().expect("env-derived config should be valid");
543        assert_eq!(cfg.engines.len(), 2);
544        assert!(cfg.default_engine().is_some());
545        assert_eq!(cfg.default_engine().unwrap().name, "default");
546    }
547
548    // 6. File present + env vars set -> file wins; test via RuntimeConfig.
549    #[test]
550    fn test_file_overrides_env() {
551        let dir = tempfile::tempdir().unwrap();
552        let path = write_toml(
553            &dir,
554            r#"
555[[engines]]
556name = "file-engine"
557model = "all-minilm-l6-v2"
558default = true
559"#,
560        );
561
562        // File load succeeds even if env vars would provide a different model.
563        // The caller (RuntimeConfig::from_khive_config) is responsible for
564        // checking whether env vars are also present and emitting the warning.
565        // Here we verify that KhiveConfig::load returns the file config.
566        let cfg = KhiveConfig::load(Some(&path))
567            .expect("load should succeed")
568            .expect("file should be present");
569        assert_eq!(cfg.engines[0].name, "file-engine");
570    }
571
572    // 7. Duplicate engine names -> error.
573    #[test]
574    fn test_duplicate_engine_names_rejected() {
575        let dir = tempfile::tempdir().unwrap();
576        let path = write_toml(
577            &dir,
578            r#"
579[[engines]]
580name = "shared"
581model = "all-minilm-l6-v2"
582default = true
583
584[[engines]]
585name = "shared"
586model = "paraphrase-multilingual-minilm-l12-v2"
587"#,
588        );
589        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
590        assert!(
591            matches!(err, ConfigError::DuplicateName { .. }),
592            "expected DuplicateName, got {err:?}"
593        );
594    }
595
596    // 8. Empty config file -> no engines; validate succeeds.
597    #[test]
598    fn test_empty_config_is_valid() {
599        let dir = tempfile::tempdir().unwrap();
600        let path = write_toml(&dir, "# no engines\n");
601        let cfg = KhiveConfig::load(Some(&path))
602            .expect("load should succeed")
603            .expect("file should be found");
604        assert!(cfg.engines.is_empty());
605        cfg.validate().expect("empty config should be valid");
606    }
607
608    // 9. Multi-engine config with valid positive fusion_weight -> succeeds.
609    #[test]
610    fn test_multi_engine_positive_fusion_weight() {
611        let dir = tempfile::tempdir().unwrap();
612        let path = write_toml(
613            &dir,
614            r#"
615[[engines]]
616name = "primary"
617model = "all-minilm-l6-v2"
618default = true
619fusion_weight = 0.7
620
621[[engines]]
622name = "secondary"
623model = "paraphrase-multilingual-minilm-l12-v2"
624fusion_weight = 0.3
625"#,
626        );
627        let cfg = KhiveConfig::load(Some(&path))
628            .expect("load should succeed")
629            .expect("file should be found");
630        assert_eq!(cfg.engines.len(), 2);
631        assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
632        assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
633    }
634
635    // 10. [actor] section with id -> parsed correctly.
636    #[test]
637    fn test_actor_id_parsed() {
638        let dir = tempfile::tempdir().unwrap();
639        let path = write_toml(
640            &dir,
641            r#"
642[actor]
643id = "lambda:khive"
644display_name = "Ocean's khive lambda"
645"#,
646        );
647        let cfg = KhiveConfig::load(Some(&path))
648            .expect("load should succeed")
649            .expect("file should be found");
650        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
651        assert_eq!(
652            cfg.actor.display_name.as_deref(),
653            Some("Ocean's khive lambda")
654        );
655        assert!(cfg.engines.is_empty());
656    }
657
658    // 11. [actor] section with engines -> both parsed.
659    #[test]
660    fn test_actor_and_engines_together() {
661        let dir = tempfile::tempdir().unwrap();
662        let path = write_toml(
663            &dir,
664            r#"
665[actor]
666id = "lambda:test"
667
668[[engines]]
669name = "default"
670model = "all-minilm-l6-v2"
671default = true
672"#,
673        );
674        let cfg = KhiveConfig::load(Some(&path))
675            .expect("load should succeed")
676            .expect("file should be found");
677        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
678        assert_eq!(cfg.engines.len(), 1);
679    }
680
681    // 12. Missing [actor] section -> defaults to None id (backward compat).
682    #[test]
683    fn test_actor_absent_defaults_to_none() {
684        let dir = tempfile::tempdir().unwrap();
685        let path = write_toml(
686            &dir,
687            r#"
688[[engines]]
689name = "x"
690model = "all-minilm-l6-v2"
691default = true
692"#,
693        );
694        let cfg = KhiveConfig::load(Some(&path))
695            .expect("load should succeed")
696            .expect("file should be found");
697        assert!(
698            cfg.actor.id.is_none(),
699            "actor.id must be None when [actor] section is absent"
700        );
701    }
702
703    // 13. load_with_roots returns None when no files exist in the given roots.
704    #[test]
705    fn test_load_with_home_fallback_no_files() {
706        let project_dir = tempfile::tempdir().unwrap();
707        let home_dir = tempfile::tempdir().unwrap();
708        let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()));
709        assert!(
710            result.expect("no error expected").is_none(),
711            "should return None when no config files exist in the given roots"
712        );
713    }
714
715    // 14. load_with_home_fallback explicit path overrides search.
716    #[test]
717    fn test_load_with_home_fallback_explicit_path() {
718        let dir = tempfile::tempdir().unwrap();
719        let path = write_toml(
720            &dir,
721            r#"
722[actor]
723id = "lambda:explicit"
724"#,
725        );
726        let cfg = KhiveConfig::load_with_home_fallback(Some(&path))
727            .expect("no error expected")
728            .expect("file found");
729        assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
730    }
731
732    // 15. actor.id with an invalid namespace string -> ConfigError::InvalidActorId at load time.
733    #[test]
734    fn test_invalid_actor_id_rejected_at_load() {
735        let dir = tempfile::tempdir().unwrap();
736        let path = write_toml(
737            &dir,
738            r#"
739[actor]
740id = "bad namespace"
741"#,
742        );
743        let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
744        assert!(
745            matches!(err, ConfigError::InvalidActorId { .. }),
746            "expected InvalidActorId, got {err:?}"
747        );
748    }
749
750    // 16. actor.id = "" (empty string) -> ConfigError::InvalidActorId.
751    #[test]
752    fn test_empty_actor_id_rejected() {
753        let dir = tempfile::tempdir().unwrap();
754        let path = write_toml(
755            &dir,
756            r#"
757[actor]
758id = ""
759"#,
760        );
761        let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
762        assert!(
763            matches!(err, ConfigError::InvalidActorId { .. }),
764            "expected InvalidActorId for empty string, got {err:?}"
765        );
766    }
767
768    // 17. actor.id = "lambda:" (structurally invalid — no slug) -> ConfigError::InvalidActorId.
769    #[test]
770    fn test_malformed_actor_id_lambda_colon_only() {
771        let dir = tempfile::tempdir().unwrap();
772        let path = write_toml(
773            &dir,
774            r#"
775[actor]
776id = "lambda:"
777"#,
778        );
779        let err =
780            KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
781        assert!(
782            matches!(err, ConfigError::InvalidActorId { .. }),
783            "expected InvalidActorId for 'lambda:', got {err:?}"
784        );
785    }
786
787    // 18. runtime_config_from_khive_config applies valid actor.id to default_namespace.
788    #[test]
789    fn test_runtime_config_actor_id_applied() {
790        use crate::runtime::runtime_config_from_khive_config;
791        use crate::RuntimeConfig;
792        use khive_types::namespace::Namespace;
793
794        let cfg = KhiveConfig {
795            engines: vec![],
796            actor: ActorConfig {
797                id: Some("lambda:test-actor".to_string()),
798                display_name: None,
799            },
800            runtime: RuntimeSectionConfig::default(),
801        };
802        cfg.validate().expect("valid config");
803
804        let base = RuntimeConfig::default();
805        let result = runtime_config_from_khive_config(&cfg, base);
806        assert_eq!(
807            result.default_namespace,
808            Namespace::parse("lambda:test-actor").unwrap(),
809            "actor.id must become default_namespace"
810        );
811    }
812
813    // 19. runtime_config_from_khive_config with no actor preserves base namespace.
814    #[test]
815    fn test_runtime_config_no_actor_preserves_base() {
816        use crate::runtime::runtime_config_from_khive_config;
817        use crate::RuntimeConfig;
818        use khive_types::namespace::Namespace;
819
820        let cfg = KhiveConfig {
821            engines: vec![],
822            actor: ActorConfig {
823                id: None,
824                display_name: None,
825            },
826            runtime: RuntimeSectionConfig::default(),
827        };
828        cfg.validate().expect("valid config");
829
830        let base_ns = Namespace::parse("lambda:base").unwrap();
831        let base = RuntimeConfig {
832            default_namespace: base_ns.clone(),
833            ..RuntimeConfig::default()
834        };
835        let result = runtime_config_from_khive_config(&cfg, base);
836        assert_eq!(
837            result.default_namespace, base_ns,
838            "no actor.id must leave base namespace unchanged"
839        );
840    }
841
842    // 20. load_with_roots: khive.toml (tier 2) wins over .khive/config.toml (tier 3).
843    #[test]
844    fn test_load_with_home_fallback_project_root_over_hidden() {
845        let dir = tempfile::tempdir().unwrap();
846
847        // Write .khive/config.toml (tier 3).
848        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
849        std::fs::write(
850            dir.path().join(".khive/config.toml"),
851            "[actor]\nid = \"lambda:hidden\"\n",
852        )
853        .unwrap();
854
855        // Write khive.toml (tier 2) — should win.
856        std::fs::write(
857            dir.path().join("khive.toml"),
858            "[actor]\nid = \"lambda:project-root\"\n",
859        )
860        .unwrap();
861
862        let cfg = KhiveConfig::load_with_roots(dir.path(), None)
863            .expect("no error expected")
864            .expect("file should be found");
865        assert_eq!(
866            cfg.actor.id.as_deref(),
867            Some("lambda:project-root"),
868            "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
869        );
870    }
871
872    // 21. load_with_roots: .khive/config.toml (tier 3) wins when khive.toml absent.
873    #[test]
874    fn test_load_with_home_fallback_hidden_over_absent_root() {
875        let dir = tempfile::tempdir().unwrap();
876
877        std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
878        std::fs::write(
879            dir.path().join(".khive/config.toml"),
880            "[actor]\nid = \"lambda:hidden-config\"\n",
881        )
882        .unwrap();
883        // No khive.toml.
884
885        let cfg = KhiveConfig::load_with_roots(dir.path(), None)
886            .expect("no error expected")
887            .expect("file should be found");
888        assert_eq!(
889            cfg.actor.id.as_deref(),
890            Some("lambda:hidden-config"),
891            ".khive/config.toml (tier 3) must be found when khive.toml is absent"
892        );
893    }
894
895    // 22. load_with_roots: ~/.khive/config.toml (tier 4) found when project files absent.
896    #[test]
897    fn test_load_with_roots_home_tier_found() {
898        let project_dir = tempfile::tempdir().unwrap();
899        let home_dir = tempfile::tempdir().unwrap();
900
901        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
902        std::fs::write(
903            home_dir.path().join(".khive/config.toml"),
904            "[actor]\nid = \"lambda:user-global\"\n",
905        )
906        .unwrap();
907        // No project-level files.
908
909        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
910            .expect("no error expected")
911            .expect("file should be found");
912        assert_eq!(
913            cfg.actor.id.as_deref(),
914            Some("lambda:user-global"),
915            "~/.khive/config.toml (tier 4) must be found when project files absent"
916        );
917    }
918
919    // 23. load_with_roots: project tier wins over home tier.
920    #[test]
921    fn test_load_with_roots_project_wins_over_home() {
922        let project_dir = tempfile::tempdir().unwrap();
923        let home_dir = tempfile::tempdir().unwrap();
924
925        // Home has a config.
926        std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
927        std::fs::write(
928            home_dir.path().join(".khive/config.toml"),
929            "[actor]\nid = \"lambda:user-global\"\n",
930        )
931        .unwrap();
932
933        // Project also has a config — should win.
934        std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
935        std::fs::write(
936            project_dir.path().join(".khive/config.toml"),
937            "[actor]\nid = \"lambda:project-wins\"\n",
938        )
939        .unwrap();
940
941        let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()))
942            .expect("no error expected")
943            .expect("file should be found");
944        assert_eq!(
945            cfg.actor.id.as_deref(),
946            Some("lambda:project-wins"),
947            "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
948        );
949    }
950}