wasmind_config/
lib.rs

1//! # Wasmind Configuration Management
2//!
3//! This crate provides configuration management for the Wasmind actor orchestration system.
4
5use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
6use serde::{Deserialize, de::DeserializeOwned};
7use snafu::{Location, OptionExt, ResultExt, Snafu};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use url::Url;
11
12#[derive(Debug, Snafu)]
13pub enum Error {
14    #[snafu(display("Config directory error"))]
15    Config {
16        #[snafu(source)]
17        source: etcetera::HomeDirError,
18        #[snafu(implicit)]
19        location: Location,
20    },
21
22    #[snafu(display("IO error: {}", source))]
23    Io {
24        #[snafu(source)]
25        source: std::io::Error,
26        #[snafu(implicit)]
27        location: Location,
28    },
29
30    #[snafu(display("Error reading file: `{:?}` - {}", file, source))]
31    ReadingFile {
32        file: PathBuf,
33        #[snafu(source)]
34        source: std::io::Error,
35        #[snafu(implicit)]
36        location: Location,
37    },
38
39    #[snafu(display("TOML parsing error: {}", source))]
40    TomlParse {
41        #[snafu(source)]
42        source: toml::de::Error,
43        #[snafu(implicit)]
44        location: Location,
45    },
46
47    #[snafu(display("Invalid configuration section '[{}]': {}", section, reason))]
48    InvalidSection {
49        section: String,
50        reason: String,
51        #[snafu(implicit)]
52        location: Location,
53    },
54
55    #[snafu(display("Actor manifest not found: {}", path.display()))]
56    InvalidManifestLocation {
57        path: PathBuf,
58        #[snafu(implicit)]
59        location: Location,
60    },
61}
62
63#[derive(Clone, Debug, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum GitRef {
66    Branch(String),
67    Tag(String),
68    Rev(String),
69}
70
71#[derive(Clone, Debug, Deserialize)]
72pub struct PathSource {
73    pub path: String,
74}
75
76#[derive(Clone, Debug, Deserialize)]
77pub struct Repository {
78    pub git: Url,
79    pub git_ref: Option<GitRef>,
80    pub sub_dir: Option<String>,
81}
82
83#[derive(Clone, Debug, Deserialize)]
84#[serde(untagged)]
85pub enum ActorSource {
86    Path(PathSource),
87    Git(Repository),
88}
89
90#[derive(Clone, Debug, Deserialize)]
91pub struct Actor {
92    /// The logical name of the actor. This is populated from the TOML key, not the value.
93    #[serde(skip)]
94    pub name: String,
95    pub source: ActorSource,
96    #[serde(default)]
97    pub config: Option<toml::Table>,
98    #[serde(default)]
99    pub auto_spawn: bool,
100    #[serde(default)]
101    pub required_spawn_with: Vec<String>,
102}
103
104#[derive(Clone, Debug, Deserialize)]
105pub struct ActorOverride {
106    /// The logical name of the actor override. This is populated from the TOML key, not the value.
107    #[serde(skip)]
108    pub name: String,
109    #[serde(default)]
110    pub source: Option<ActorSource>,
111    #[serde(default)]
112    pub config: Option<toml::Table>,
113    #[serde(default)]
114    pub auto_spawn: Option<bool>,
115    #[serde(default)]
116    pub required_spawn_with: Option<Vec<String>>,
117}
118
119#[derive(Clone, Debug)]
120pub struct Config {
121    raw_config: toml::Table,
122    pub actors: Vec<Actor>,
123    pub actor_overrides: Vec<ActorOverride>,
124    pub starting_actors: Vec<String>,
125}
126
127impl Config {
128    pub fn parse_section<T: DeserializeOwned>(
129        &self,
130        section_name: &str,
131    ) -> Result<Option<T>, Error> {
132        if let Some(section) = self.raw_config.get(section_name) {
133            let value: T = section.clone().try_into().context(TomlParseSnafu)?;
134            Ok(Some(value))
135        } else {
136            Ok(None)
137        }
138    }
139
140    pub fn get_raw_table(&self, section_name: &str) -> Option<&toml::Table> {
141        self.raw_config.get(section_name)?.as_table()
142    }
143}
144
145pub fn load_from_path<P: AsRef<Path> + ToOwned<Owned = PathBuf>>(path: P) -> Result<Config, Error> {
146    let content = std::fs::read_to_string(&path).context(ReadingFileSnafu {
147        file: path.to_owned(),
148    })?;
149    let raw_config: toml::Table = toml::from_str(&content).context(TomlParseSnafu)?;
150
151    let actors = if let Some(actors_section) = raw_config.get("actors") {
152        let actors_table = actors_section.as_table().context(InvalidSectionSnafu {
153            section: "actors",
154            reason: "must be a table, not a different type",
155        })?;
156
157        let mut actors_vec = Vec::new();
158        for (name, value) in actors_table {
159            let mut actor: Actor = value.clone().try_into().context(TomlParseSnafu)?;
160            actor.name.clone_from(name);
161            actors_vec.push(actor);
162        }
163        actors_vec
164    } else {
165        Vec::new()
166    };
167
168    let starting_actors = if let Some(starting_actors_section) = raw_config.get("starting_actors") {
169        starting_actors_section
170            .clone()
171            .try_into()
172            .context(TomlParseSnafu)?
173    } else {
174        Vec::new()
175    };
176
177    // Parse actor_overrides section
178    let actor_overrides = if let Some(overrides_section) = raw_config.get("actor_overrides") {
179        let overrides_table = overrides_section.as_table().context(InvalidSectionSnafu {
180            section: "actor_overrides",
181            reason: "must be a table, not a different type",
182        })?;
183
184        let mut overrides_vec = Vec::new();
185        for (name, value) in overrides_table {
186            // TOML naturally handles dotted keys like [actor_overrides.logger.config]
187            // They become nested tables, so we can just deserialize normally
188            let mut actor_override: ActorOverride =
189                value.clone().try_into().context(TomlParseSnafu)?;
190            actor_override.name.clone_from(name);
191            overrides_vec.push(actor_override);
192        }
193        overrides_vec
194    } else {
195        Vec::new()
196    };
197
198    Ok(Config {
199        raw_config,
200        actors,
201        actor_overrides,
202        starting_actors,
203    })
204}
205
206pub fn load_default_config() -> Result<Config, Error> {
207    let config_path = get_config_file_path()?;
208    load_from_path(config_path)
209}
210
211fn get_app_strategy() -> Result<impl AppStrategy, Error> {
212    choose_app_strategy(AppStrategyArgs {
213        top_level_domain: "com".to_string(),
214        author: "wasmind".to_string(),
215        app_name: "wasmind".to_string(),
216    })
217    .context(ConfigSnafu)
218}
219
220pub fn get_config_dir() -> Result<PathBuf, Error> {
221    Ok(get_app_strategy()?.config_dir())
222}
223
224pub fn get_cache_dir() -> Result<PathBuf, Error> {
225    Ok(get_app_strategy()?.cache_dir())
226}
227
228pub fn get_config_file_path() -> Result<PathBuf, Error> {
229    Ok(get_config_dir()?.join("config.toml"))
230}
231
232#[derive(Clone, Debug, Deserialize)]
233pub struct DependencyConfig {
234    pub source: ActorSource,
235    #[serde(default)]
236    pub auto_spawn: Option<bool>,
237    #[serde(default)]
238    pub config: Option<toml::Table>,
239    #[serde(default)]
240    pub required_spawn_with: Option<Vec<String>>,
241}
242
243#[derive(Clone, Debug, Deserialize)]
244pub struct ActorManifest {
245    pub actor_id: String,
246    #[serde(default)]
247    pub dependencies: HashMap<String, DependencyConfig>,
248    #[serde(default)]
249    pub required_spawn_with: Vec<String>,
250}
251
252impl ActorManifest {
253    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
254        let manifest_path = path.as_ref().join("Wasmind.toml");
255        if !manifest_path.exists() {
256            return InvalidManifestLocationSnafu {
257                path: manifest_path,
258            }
259            .fail();
260        }
261
262        let content = std::fs::read_to_string(&manifest_path).context(ReadingFileSnafu {
263            file: manifest_path.clone(),
264        })?;
265
266        let manifest: ActorManifest = toml::from_str(&content).context(TomlParseSnafu)?;
267        Ok(manifest)
268    }
269}
270
271pub fn get_actors_cache_dir() -> Result<PathBuf, Error> {
272    Ok(get_cache_dir()?.join("actors"))
273}
274
275pub fn get_log_file_path() -> Result<PathBuf, Error> {
276    let data_dir = get_data_dir()?;
277    Ok(data_dir.join("wasmind.log"))
278}
279
280pub fn get_data_dir() -> Result<PathBuf, Error> {
281    Ok(get_app_strategy()?.data_dir())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::fs;
288    use tempfile::TempDir;
289
290    #[test]
291    fn test_actor_manifest_parsing() {
292        let temp_dir = TempDir::new().unwrap();
293        let manifest_content = r#"
294actor_id = "test-company:test-actor"
295
296[dependencies.logger]
297source = { path = "../logger" }
298auto_spawn = true
299
300[dependencies.logger.config]
301level = "info"
302format = "json"
303
304[dependencies.helper]
305source = { git = "https://github.com/test/helper", git_ref = { branch = "main" } }
306"#;
307
308        fs::write(temp_dir.path().join("Wasmind.toml"), manifest_content).unwrap();
309
310        let manifest = ActorManifest::from_path(temp_dir.path()).unwrap();
311        assert_eq!(manifest.actor_id, "test-company:test-actor");
312        assert_eq!(manifest.dependencies.len(), 2);
313
314        let logger_dep = &manifest.dependencies["logger"];
315        assert!(matches!(logger_dep.source, ActorSource::Path(_)));
316        assert_eq!(logger_dep.auto_spawn, Some(true));
317        assert!(logger_dep.config.is_some());
318
319        let helper_dep = &manifest.dependencies["helper"];
320        assert!(matches!(helper_dep.source, ActorSource::Git(_)));
321    }
322
323    #[test]
324    fn test_config_parsing_with_table_format() {
325        let temp_dir = TempDir::new().unwrap();
326        let config_content = r#"
327starting_actors = ["assistant", "coordinator"]
328
329[actors.assistant]
330source = { path = "./actors/assistant" }
331auto_spawn = true
332
333[actors.assistant.config]
334model = "gpt-4"
335temperature = 0.7
336
337[actors.coordinator]
338source = { git = "https://github.com/test/coordinator", git_ref = { tag = "v1.0.0" } }
339
340[actors.bash_executor]
341source = { path = "./actors/bash" }
342auto_spawn = false
343"#;
344
345        let config_path = temp_dir.path().join("config.toml");
346        fs::write(&config_path, config_content).unwrap();
347
348        let config = load_from_path(config_path).unwrap();
349        assert_eq!(config.starting_actors, vec!["assistant", "coordinator"]);
350        assert_eq!(config.actors.len(), 3);
351
352        // Find actors by name
353        let assistant = config
354            .actors
355            .iter()
356            .find(|a| a.name == "assistant")
357            .unwrap();
358        assert!(assistant.auto_spawn);
359        assert!(matches!(assistant.source, ActorSource::Path(_)));
360        assert!(assistant.config.is_some());
361
362        let coordinator = config
363            .actors
364            .iter()
365            .find(|a| a.name == "coordinator")
366            .unwrap();
367        assert!(matches!(coordinator.source, ActorSource::Git(_)));
368        assert!(!coordinator.auto_spawn); // defaults to false
369    }
370
371    #[test]
372    fn test_invalid_actors_section_type() {
373        let temp_dir = TempDir::new().unwrap();
374
375        // actors section as a string instead of a table
376        let invalid_config = r#"
377actors = "this should be a table, not a string"
378"#;
379
380        let config_path = temp_dir.path().join("config.toml");
381        fs::write(&config_path, invalid_config).unwrap();
382
383        let result = load_from_path(config_path);
384        assert!(result.is_err());
385        let error = result.unwrap_err();
386        assert!(
387            error
388                .to_string()
389                .contains("Invalid configuration section '[actors]'")
390        );
391    }
392
393    #[test]
394    fn test_invalid_actor_overrides_section_type() {
395        let temp_dir = TempDir::new().unwrap();
396
397        // actor_overrides section as an array instead of a table
398        let invalid_config = r#"
399actor_overrides = ["should", "be", "a", "table"]
400"#;
401
402        let config_path = temp_dir.path().join("config.toml");
403        fs::write(&config_path, invalid_config).unwrap();
404
405        let result = load_from_path(config_path);
406        assert!(result.is_err());
407        let error = result.unwrap_err();
408        assert!(
409            error
410                .to_string()
411                .contains("Invalid configuration section '[actor_overrides]'")
412        );
413    }
414
415    #[test]
416    fn test_actor_overrides_parsing() {
417        let temp_dir = TempDir::new().unwrap();
418        let config_content = r#"
419[actors.my_assistant]
420source = { path = "./actors/assistant" }
421auto_spawn = true
422
423[actor_overrides.logger]
424source = { path = "./custom_logger" }
425auto_spawn = false
426
427[actor_overrides.logger.config]
428level = "debug"
429format = "json"
430
431[actor_overrides.database.config]
432connection_string = "postgres://localhost/test"
433"#;
434
435        let config_path = temp_dir.path().join("config.toml");
436        fs::write(&config_path, config_content).unwrap();
437
438        let config = load_from_path(config_path).unwrap();
439
440        // Check actors
441        assert_eq!(config.actors.len(), 1);
442        assert!(config.actors.iter().any(|a| a.name == "my_assistant"));
443
444        // Check overrides
445        assert_eq!(config.actor_overrides.len(), 2);
446
447        // Find logger override
448        let logger_override = config
449            .actor_overrides
450            .iter()
451            .find(|o| o.name == "logger")
452            .unwrap();
453        assert!(matches!(logger_override.source, Some(ActorSource::Path(_))));
454        assert_eq!(logger_override.auto_spawn, Some(false));
455        assert!(logger_override.config.is_some());
456        let logger_config = logger_override.config.as_ref().unwrap();
457        assert_eq!(
458            logger_config.get("level").unwrap().as_str().unwrap(),
459            "debug"
460        );
461        assert_eq!(
462            logger_config.get("format").unwrap().as_str().unwrap(),
463            "json"
464        );
465
466        // Find database override (config-only)
467        let db_override = config
468            .actor_overrides
469            .iter()
470            .find(|o| o.name == "database")
471            .unwrap();
472        assert!(db_override.source.is_none());
473        assert!(db_override.auto_spawn.is_none());
474        assert!(db_override.config.is_some());
475        let db_config = db_override.config.as_ref().unwrap();
476        assert_eq!(
477            db_config
478                .get("connection_string")
479                .unwrap()
480                .as_str()
481                .unwrap(),
482            "postgres://localhost/test"
483        );
484    }
485
486    #[test]
487    fn test_mixed_actor_and_override_definitions() {
488        let temp_dir = TempDir::new().unwrap();
489        let config_content = r#"
490starting_actors = ["assistant", "executor"]
491
492[actors.assistant]
493source = { path = "./actors/assistant" }
494auto_spawn = true
495required_spawn_with = ["logger"]
496
497[actors.executor]
498source = { path = "./actors/bash" }
499
500[actors.executor.config]
501shell = "/bin/zsh"
502timeout = 30
503
504[actor_overrides.logger]
505auto_spawn = true
506
507[actor_overrides.logger.config]
508level = "info"
509"#;
510
511        let config_path = temp_dir.path().join("config.toml");
512        fs::write(&config_path, config_content).unwrap();
513
514        let config = load_from_path(config_path).unwrap();
515
516        // Check starting actors
517        assert_eq!(config.starting_actors, vec!["assistant", "executor"]);
518
519        // Check actors
520        assert_eq!(config.actors.len(), 2);
521
522        let assistant = config
523            .actors
524            .iter()
525            .find(|a| a.name == "assistant")
526            .unwrap();
527        assert_eq!(assistant.required_spawn_with, vec!["logger"]);
528
529        let executor = config.actors.iter().find(|a| a.name == "executor").unwrap();
530        assert!(executor.config.is_some());
531        let exec_config = executor.config.as_ref().unwrap();
532        assert_eq!(
533            exec_config.get("shell").unwrap().as_str().unwrap(),
534            "/bin/zsh"
535        );
536        assert_eq!(
537            exec_config.get("timeout").unwrap().as_integer().unwrap(),
538            30
539        );
540
541        // Check overrides
542        assert_eq!(config.actor_overrides.len(), 1);
543        let logger_override = config
544            .actor_overrides
545            .iter()
546            .find(|o| o.name == "logger")
547            .unwrap();
548        assert_eq!(logger_override.auto_spawn, Some(true));
549    }
550
551    #[test]
552    fn test_invalid_starting_actors_type() {
553        let temp_dir = TempDir::new().unwrap();
554
555        // starting_actors as a string instead of an array
556        let invalid_config = r#"
557starting_actors = "should be an array"
558"#;
559
560        let config_path = temp_dir.path().join("config.toml");
561        fs::write(&config_path, invalid_config).unwrap();
562
563        let result = load_from_path(config_path);
564        assert!(result.is_err());
565        let error = result.unwrap_err();
566        assert!(error.to_string().contains("TOML parsing error"));
567    }
568
569    #[test]
570    fn test_invalid_actor_configuration() {
571        let temp_dir = TempDir::new().unwrap();
572
573        // Actor with invalid source field
574        let invalid_config = r#"
575[actors.bad_actor]
576source = "should be a table with path or url"
577"#;
578
579        let config_path = temp_dir.path().join("config.toml");
580        fs::write(&config_path, invalid_config).unwrap();
581
582        let result = load_from_path(config_path);
583        assert!(result.is_err());
584        let error = result.unwrap_err();
585        assert!(error.to_string().contains("TOML parsing error"));
586    }
587
588    #[test]
589    fn test_invalid_manifest_file() {
590        let temp_dir = TempDir::new().unwrap();
591
592        // Create an invalid manifest file
593        let invalid_manifest = r#"
594# Missing required actor_id field
595dependencies = {}
596"#;
597        fs::write(temp_dir.path().join("Wasmind.toml"), invalid_manifest).unwrap();
598
599        let result = ActorManifest::from_path(temp_dir.path());
600        assert!(result.is_err());
601        let error = result.unwrap_err();
602        assert!(error.to_string().contains("TOML parsing error"));
603    }
604
605    #[test]
606    fn test_missing_manifest_file() {
607        let temp_dir = TempDir::new().unwrap();
608
609        // Don't create any Wasmind.toml file
610        let result = ActorManifest::from_path(temp_dir.path());
611        assert!(result.is_err());
612        let error = result.unwrap_err();
613        assert!(error.to_string().contains("Actor manifest not found"));
614    }
615
616    #[test]
617    fn test_toml_dotted_key_parsing() {
618        // Test how TOML handles dotted keys vs nested tables
619        let dotted_toml = r#"
620[actor_overrides.logger.config]
621level = "debug"
622format = "json"
623"#;
624
625        let nested_toml = r#"
626[actor_overrides.logger]
627config = { level = "debug", format = "json" }
628"#;
629
630        let dotted_parsed: toml::Table = toml::from_str(dotted_toml).unwrap();
631        let nested_parsed: toml::Table = toml::from_str(nested_toml).unwrap();
632
633        // Both should create the same structure
634        let dotted_overrides = dotted_parsed
635            .get("actor_overrides")
636            .unwrap()
637            .as_table()
638            .unwrap();
639        let nested_overrides = nested_parsed
640            .get("actor_overrides")
641            .unwrap()
642            .as_table()
643            .unwrap();
644
645        // Both should have "logger" as a key
646        assert!(dotted_overrides.contains_key("logger"));
647        assert!(nested_overrides.contains_key("logger"));
648
649        // Both logger values should be tables with a "config" key
650        let dotted_logger = dotted_overrides.get("logger").unwrap().as_table().unwrap();
651        let nested_logger = nested_overrides.get("logger").unwrap().as_table().unwrap();
652
653        assert!(dotted_logger.contains_key("config"));
654        assert!(nested_logger.contains_key("config"));
655
656        // The config values should be identical
657        let dotted_config = dotted_logger.get("config").unwrap();
658        let nested_config = nested_logger.get("config").unwrap();
659
660        assert_eq!(dotted_config, nested_config);
661    }
662}