Skip to main content

agentctl/
config.rs

1use std::path::PathBuf;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct HubEntry {
8    pub id: String,
9    pub index_url: String,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub git_url: Option<String>,
12    #[serde(default = "default_true")]
13    pub enabled: bool,
14    #[serde(default = "default_ttl")]
15    pub ttl_hours: u64,
16}
17
18fn default_true() -> bool {
19    true
20}
21
22fn default_ttl() -> u64 {
23    6
24}
25
26#[derive(Debug, Default, Serialize, Deserialize)]
27pub struct Config {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub skills_root: Option<String>,
30    #[serde(default)]
31    pub skill_hubs: Vec<HubEntry>,
32    #[serde(default)]
33    pub doc_hubs: Vec<HubEntry>,
34}
35
36pub fn config_path() -> PathBuf {
37    dirs::home_dir()
38        .unwrap_or_else(|| PathBuf::from("."))
39        .join(".agentctl")
40        .join("config.json")
41}
42
43impl Config {
44    pub fn load_from(path: &std::path::Path) -> Result<Self> {
45        if !path.exists() {
46            return Ok(Self::default());
47        }
48        Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
49    }
50
51    pub fn save_to(&self, path: &std::path::Path) -> Result<()> {
52        if let Some(parent) = path.parent() {
53            std::fs::create_dir_all(parent)?;
54        }
55        std::fs::write(path, serde_json::to_string_pretty(self)?)?;
56        Ok(())
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use std::path::Path;
64    use tempfile::TempDir;
65
66    fn fixture(name: &str) -> PathBuf {
67        Path::new(env!("CARGO_MANIFEST_DIR"))
68            .join("tests/fixtures")
69            .join(name)
70    }
71
72    #[test]
73    fn load_from_missing_returns_default() {
74        let dir = TempDir::new().unwrap();
75        let cfg = Config::load_from(&dir.path().join("config.json")).unwrap();
76        assert!(cfg.skill_hubs.is_empty());
77        assert!(cfg.doc_hubs.is_empty());
78    }
79
80    #[test]
81    fn empty_json_object_loads_as_empty_config() {
82        let cfg = Config::load_from(&fixture("config-empty.json")).unwrap();
83        assert!(cfg.skill_hubs.is_empty());
84        assert!(cfg.doc_hubs.is_empty());
85    }
86
87    #[test]
88    fn defaults_applied_on_missing_fields() {
89        let cfg = Config::load_from(&fixture("config-defaults.json")).unwrap();
90        assert_eq!(cfg.skill_hubs[0].id, "minimal-hub");
91        assert!(cfg.skill_hubs[0].enabled);
92        assert_eq!(cfg.skill_hubs[0].ttl_hours, 6);
93        assert!(cfg.skill_hubs[0].git_url.is_none());
94    }
95
96    #[test]
97    fn load_valid_config() {
98        let cfg = Config::load_from(&fixture("config-valid.json")).unwrap();
99        assert_eq!(cfg.skill_hubs.len(), 1);
100        assert_eq!(cfg.skill_hubs[0].id, "agent-foundation");
101        assert_eq!(cfg.skill_hubs[0].ttl_hours, 12);
102        assert!(cfg.skill_hubs[0].git_url.is_some());
103        assert_eq!(cfg.doc_hubs.len(), 1);
104        assert!(!cfg.doc_hubs[0].enabled);
105    }
106
107    #[test]
108    fn save_and_load_roundtrip() {
109        let dir = TempDir::new().unwrap();
110        let path = dir.path().join("config.json");
111        let src = Config::load_from(&fixture("config-valid.json")).unwrap();
112        src.save_to(&path).unwrap();
113        let loaded = Config::load_from(&path).unwrap();
114        assert_eq!(loaded.skill_hubs[0], src.skill_hubs[0]);
115        assert_eq!(loaded.doc_hubs[0], src.doc_hubs[0]);
116    }
117}