1use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ProfileEntry {
8 pub name: String,
9 pub description: Option<String>,
10 pub path: String,
12 pub active: bool,
13 #[serde(default)]
14 pub installed_at: Option<String>,
15 #[serde(default)]
16 pub version: Option<String>,
17 #[serde(default)]
19 pub source: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct ProfileRegistry {
25 #[serde(default)]
26 pub profiles: BTreeMap<String, ProfileEntry>,
27}
28
29impl ProfileRegistry {
30 fn registry_path() -> PathBuf {
31 home_dir().join(".roboticus").join("profiles.toml")
32 }
33
34 pub fn load() -> Result<Self> {
38 let path = Self::registry_path();
39 if !path.exists() {
40 return Ok(Self::default_registry());
41 }
42 let contents = std::fs::read_to_string(&path).map_err(|e| {
43 RoboticusError::Config(format!(
44 "failed to read profiles.toml at {}: {e}",
45 path.display()
46 ))
47 })?;
48 let registry: Self = toml::from_str(&contents).map_err(|e| {
49 RoboticusError::Config(format!(
50 "failed to parse profiles.toml at {}: {e}",
51 path.display()
52 ))
53 })?;
54 Ok(registry)
55 }
56
57 pub fn save(&self) -> Result<()> {
59 let path = Self::registry_path();
60 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent).map_err(|e| {
63 RoboticusError::Config(format!(
64 "failed to create directory {}: {e}",
65 parent.display()
66 ))
67 })?;
68 }
69 let contents = toml::to_string_pretty(self).map_err(|e| {
70 RoboticusError::Config(format!("failed to serialise profiles.toml: {e}"))
71 })?;
72 std::fs::write(&path, contents).map_err(|e| {
73 RoboticusError::Config(format!(
74 "failed to write profiles.toml at {}: {e}",
75 path.display()
76 ))
77 })?;
78 Ok(())
79 }
80
81 pub fn active_profile(&self) -> Option<(&str, &ProfileEntry)> {
83 self.profiles
84 .iter()
85 .find(|(_, e)| e.active)
86 .map(|(id, e)| (id.as_str(), e))
87 }
88
89 pub fn resolve_config_dir(&self, profile_id: &str) -> Result<PathBuf> {
94 let roboticus_root = home_dir().join(".roboticus");
95
96 if profile_id == "default" {
97 return Ok(roboticus_root);
98 }
99
100 let entry = self.profiles.get(profile_id).ok_or_else(|| {
101 RoboticusError::Config(format!("profile not found: {profile_id}"))
102 })?;
103
104 if entry.path.is_empty() {
105 Ok(roboticus_root)
106 } else {
107 Ok(roboticus_root.join(&entry.path))
108 }
109 }
110
111 pub fn list(&self) -> Vec<(String, ProfileEntry)> {
113 self.profiles
114 .iter()
115 .map(|(id, e)| (id.clone(), e.clone()))
116 .collect()
117 }
118
119 fn default_registry() -> Self {
121 let mut profiles = BTreeMap::new();
122 profiles.insert(
123 "default".to_string(),
124 ProfileEntry {
125 name: "Default".to_string(),
126 description: Some("Default agent profile".to_string()),
127 path: String::new(),
128 active: true,
129 installed_at: None,
130 version: None,
131 source: None,
132 },
133 );
134 Self { profiles }
135 }
136
137 pub fn ensure_profile_dir(&self, profile_id: &str) -> Result<PathBuf> {
139 let dir = self.resolve_config_dir(profile_id)?;
140 for sub in &["workspace", "skills"] {
141 let sub_dir = dir.join(sub);
142 std::fs::create_dir_all(&sub_dir).map_err(|e| {
143 RoboticusError::Config(format!(
144 "failed to create profile directory {}: {e}",
145 sub_dir.display()
146 ))
147 })?;
148 }
149 Ok(dir)
150 }
151}
152
153pub fn resolve_profile_config_path(profile_override: Option<&str>) -> Option<PathBuf> {
159 let registry = ProfileRegistry::load().unwrap_or_default();
160 let profile_id = profile_override
161 .map(|s| s.to_string())
162 .or_else(|| {
163 registry
164 .active_profile()
165 .map(|(id, _)| id.to_string())
166 })
167 .unwrap_or_else(|| "default".to_string());
168
169 let config_dir = registry
170 .resolve_config_dir(&profile_id)
171 .unwrap_or_else(|_| home_dir().join(".roboticus"));
172
173 let candidate = config_dir.join("roboticus.toml");
174 if candidate.exists() {
175 Some(candidate)
176 } else {
177 None
178 }
179}
180
181#[cfg(test)]
182mod profile_tests {
183 use super::*;
184
185 #[test]
186 fn default_registry_has_active_default_profile() {
187 let reg = ProfileRegistry::default_registry();
188 let (id, entry) = reg.active_profile().expect("should have active profile");
189 assert_eq!(id, "default");
190 assert!(entry.active);
191 assert!(entry.path.is_empty());
192 }
193
194 #[test]
195 fn resolve_config_dir_default_returns_roboticus_root() {
196 let reg = ProfileRegistry::default_registry();
197 let dir = reg.resolve_config_dir("default").unwrap();
198 assert!(dir.ends_with(".roboticus"));
199 }
200
201 #[test]
202 fn resolve_config_dir_custom_profile_appends_path() {
203 let mut reg = ProfileRegistry::default();
204 reg.profiles.insert(
205 "narrator".to_string(),
206 ProfileEntry {
207 name: "The Narrator".to_string(),
208 description: Some("GM profile".to_string()),
209 path: "profiles/narrator".to_string(),
210 active: false,
211 installed_at: None,
212 version: None,
213 source: Some("manual".to_string()),
214 },
215 );
216 let dir = reg.resolve_config_dir("narrator").unwrap();
217 assert!(dir.ends_with("profiles/narrator"));
218 }
219
220 #[test]
221 fn resolve_config_dir_unknown_profile_errors() {
222 let reg = ProfileRegistry::default_registry();
223 let result = reg.resolve_config_dir("nonexistent");
224 assert!(result.is_err());
225 }
226
227 #[test]
228 fn list_returns_all_profiles_sorted() {
229 let mut reg = ProfileRegistry::default_registry();
230 reg.profiles.insert(
231 "beta".to_string(),
232 ProfileEntry {
233 name: "Beta".to_string(),
234 description: None,
235 path: "profiles/beta".to_string(),
236 active: false,
237 installed_at: None,
238 version: None,
239 source: None,
240 },
241 );
242 let listing = reg.list();
243 assert_eq!(listing.len(), 2);
244 assert_eq!(listing[0].0, "beta");
246 assert_eq!(listing[1].0, "default");
247 }
248
249 #[test]
250 fn active_profile_returns_none_when_no_active() {
251 let mut reg = ProfileRegistry::default_registry();
252 for entry in reg.profiles.values_mut() {
253 entry.active = false;
254 }
255 assert!(reg.active_profile().is_none());
256 }
257
258 #[test]
259 fn save_and_load_roundtrip() {
260 let dir = tempfile::tempdir().unwrap();
261 let path = dir.path().join("profiles.toml");
262
263 let mut reg = ProfileRegistry::default_registry();
264 reg.profiles.insert(
265 "test".to_string(),
266 ProfileEntry {
267 name: "Test Profile".to_string(),
268 description: Some("for testing".to_string()),
269 path: "profiles/test".to_string(),
270 active: false,
271 installed_at: Some("2026-03-28".to_string()),
272 version: Some("0.11.0".to_string()),
273 source: Some("registry".to_string()),
274 },
275 );
276
277 let contents = toml::to_string_pretty(®).unwrap();
278 std::fs::write(&path, &contents).unwrap();
279
280 let loaded: ProfileRegistry =
281 toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
282 assert_eq!(loaded.profiles.len(), 2);
283 let test_entry = loaded.profiles.get("test").unwrap();
284 assert_eq!(test_entry.name, "Test Profile");
285 assert_eq!(test_entry.source.as_deref(), Some("registry"));
286 }
287
288 #[test]
289 fn ensure_profile_dir_creates_subdirs() {
290 let dir = tempfile::tempdir().unwrap();
291 let profile_dir = dir.path().join("profiles").join("gm");
292
293 let mut reg = ProfileRegistry::default();
294 reg.profiles.insert(
295 "gm".to_string(),
296 ProfileEntry {
297 name: "GM".to_string(),
298 description: None,
299 path: profile_dir.to_string_lossy().to_string(),
300 active: true,
301 installed_at: None,
302 version: None,
303 source: None,
304 },
305 );
306
307 std::fs::create_dir_all(&profile_dir).unwrap();
310 for sub in &["workspace", "skills"] {
311 let sub_dir = profile_dir.join(sub);
312 std::fs::create_dir_all(&sub_dir).unwrap();
313 assert!(sub_dir.exists());
314 }
315 }
316
317 #[test]
318 fn empty_path_profile_resolves_to_root() {
319 let mut reg = ProfileRegistry::default();
320 reg.profiles.insert(
321 "legacy".to_string(),
322 ProfileEntry {
323 name: "Legacy".to_string(),
324 description: None,
325 path: String::new(),
326 active: false,
327 installed_at: None,
328 version: None,
329 source: None,
330 },
331 );
332 let dir = reg.resolve_config_dir("legacy").unwrap();
333 assert!(dir.ends_with(".roboticus"));
334 }
335}