Skip to main content

roboticus_core/config/
profiles.rs

1// Note: this file is included into config.rs via include!(), so all imports from
2// config.rs (std::path, serde, crate::error) are already in scope.
3use std::collections::BTreeMap;
4
5/// A single entry in the profile registry.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ProfileEntry {
8    pub name: String,
9    pub description: Option<String>,
10    /// Path relative to `~/.roboticus/`. Empty string means the root itself (the default profile).
11    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    /// One of "registry", "local", "manual".
18    #[serde(default)]
19    pub source: Option<String>,
20}
21
22/// The `~/.roboticus/profiles.toml` file structure.
23#[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    /// Load from `~/.roboticus/profiles.toml`, creating a default registry if the file is
35    /// absent.  A missing file is not an error — it simply means the user has not yet created
36    /// any named profiles; we treat the `~/.roboticus/` root as the active default profile.
37    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    /// Save to `~/.roboticus/profiles.toml`.
58    pub fn save(&self) -> Result<()> {
59        let path = Self::registry_path();
60        // Ensure parent directory exists.
61        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    /// Return the active profile as `(id, entry)`, or `None` if no profile is marked active.
82    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    /// Resolve the configuration directory for the given profile ID.
90    ///
91    /// * `"default"` (or any profile whose `path` is empty) → `~/.roboticus/`
92    /// * Any other profile → `~/.roboticus/<entry.path>/`
93    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    /// List all profiles as `(id, entry)` pairs in key order.
112    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    /// Build the fallback registry that represents an install with no `profiles.toml`.
120    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    /// Ensure the profile directory (and standard sub-directories) exist on disk.
138    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
153/// Convenience wrapper: load the registry, resolve the active (or override) profile, and return
154/// the path to that profile's `roboticus.toml`.
155///
156/// `profile_override` is the value of the `--profile` CLI flag, if provided.
157/// Returns `None` if the config file does not exist (caller may fall back to built-in defaults).
158pub 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        // BTreeMap is sorted — "beta" < "default"
245        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(&reg).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        // Override resolve_config_dir by testing ensure_profile_dir indirectly:
308        // the subdirs workspace/ and skills/ should be created
309        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}