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}