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