Skip to main content

khive_runtime/
engine_config.rs

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