1use 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 #[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 #[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 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 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 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); }
370
371 #[test]
372 fn test_invalid_actors_section_type() {
373 let temp_dir = TempDir::new().unwrap();
374
375 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 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 assert_eq!(config.actors.len(), 1);
442 assert!(config.actors.iter().any(|a| a.name == "my_assistant"));
443
444 assert_eq!(config.actor_overrides.len(), 2);
446
447 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 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 assert_eq!(config.starting_actors, vec!["assistant", "executor"]);
518
519 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 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 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 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 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 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 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 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 assert!(dotted_overrides.contains_key("logger"));
647 assert!(nested_overrides.contains_key("logger"));
648
649 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 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}