Skip to main content

orcs_runtime/config/
profile.rs

1//! Profile definitions and loading.
2//!
3//! Profiles bundle config overrides and per-component settings
4//! into a switchable unit.
5//!
6//! # TOML Format
7//!
8//! ```toml
9//! [profile]
10//! name = "rust-dev"
11//! description = "Rust development mode"
12//!
13//! [config]
14//! debug = true
15//!
16//! [config.model]
17//! default = "claude-opus-4-6"
18//!
19//! [components.skill_manager]
20//! activate = ["rust-dev", "git-workflow"]
21//! deactivate = ["python-dev"]
22//!
23//! [components.agent_mgr]
24//! default_model = "claude-opus-4-6"
25//! ```
26//!
27//! # Component Addressing
28//!
29//! Components can be addressed by last-name (`skill_manager`)
30//! or FQL (`skill::skill_manager`). Last-name is the default.
31
32use super::ConfigError;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36use tracing::debug;
37
38/// Profile directory name within `.orcs/` or `~/.orcs/`.
39pub const PROFILES_DIR: &str = "profiles";
40
41/// Profile definition parsed from TOML.
42///
43/// Contains config overrides (applied to `OrcsConfig`) and
44/// per-component settings (distributed via EventBus).
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
46#[serde(default)]
47pub struct ProfileDef {
48    /// Profile metadata.
49    pub profile: ProfileMeta,
50
51    /// Config overrides (partial `OrcsConfig`).
52    ///
53    /// These fields are merged into the active `OrcsConfig`
54    /// using the same overlay semantics as project config.
55    pub config: Option<super::OrcsConfig>,
56
57    /// Per-component settings, keyed by component last-name or FQL.
58    ///
59    /// Each entry is an arbitrary TOML table sent to the target
60    /// component as a `profile_apply` request payload.
61    ///
62    /// # Example
63    ///
64    /// ```toml
65    /// [components.skill_manager]
66    /// activate = ["rust-dev"]
67    /// ```
68    #[serde(default)]
69    pub components: HashMap<String, toml::Table>,
70}
71
72/// Profile metadata.
73#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
74#[serde(default)]
75pub struct ProfileMeta {
76    /// Profile name (must match filename without extension).
77    pub name: String,
78
79    /// Human-readable description.
80    #[serde(default)]
81    pub description: String,
82}
83
84impl ProfileDef {
85    /// Parses a profile from TOML string.
86    ///
87    /// # Errors
88    ///
89    /// Returns error if TOML parsing fails.
90    pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
91        toml::from_str(toml_str)
92    }
93
94    /// Serializes to TOML string.
95    ///
96    /// # Errors
97    ///
98    /// Returns error if serialization fails.
99    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
100        toml::to_string_pretty(self)
101    }
102
103    /// Returns the profile name.
104    #[must_use]
105    pub fn name(&self) -> &str {
106        &self.profile.name
107    }
108
109    /// Returns the profile description.
110    #[must_use]
111    pub fn description(&self) -> &str {
112        &self.profile.description
113    }
114
115    /// Returns component setting keys.
116    #[must_use]
117    pub fn component_names(&self) -> Vec<&str> {
118        self.components.keys().map(|k| k.as_str()).collect()
119    }
120}
121
122/// Lightweight profile entry for listing (no body loaded).
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct ProfileEntry {
125    /// Profile name.
126    pub name: String,
127    /// Description.
128    pub description: String,
129    /// Source file path.
130    pub path: PathBuf,
131}
132
133/// Discovers and loads profiles from filesystem directories.
134///
135/// Search order:
136/// 1. Project profiles: `.orcs/profiles/`
137/// 2. Global profiles: `~/.orcs/profiles/`
138///
139/// Project profiles take precedence over global profiles with the same name.
140#[derive(Debug, Clone)]
141pub struct ProfileStore {
142    /// Search directories (in priority order).
143    search_dirs: Vec<PathBuf>,
144}
145
146impl ProfileStore {
147    /// Creates a new store with default search dirs.
148    ///
149    /// - `~/.orcs/profiles/` (global)
150    /// - Optionally: `.orcs/profiles/` (project, if project_root given)
151    #[must_use]
152    pub fn new(project_root: Option<&Path>) -> Self {
153        let mut dirs = Vec::new();
154
155        // Project profiles (highest priority)
156        if let Some(root) = project_root {
157            dirs.push(root.join(".orcs").join(PROFILES_DIR));
158        }
159
160        // Global profiles
161        if let Some(home) = dirs::home_dir() {
162            dirs.push(home.join(".orcs").join(PROFILES_DIR));
163        }
164
165        Self { search_dirs: dirs }
166    }
167
168    /// Creates a store with explicit search directories.
169    #[must_use]
170    pub fn with_dirs(dirs: Vec<PathBuf>) -> Self {
171        Self { search_dirs: dirs }
172    }
173
174    /// Lists available profiles (name + description + path).
175    ///
176    /// Scans all search directories for `.toml` files.
177    /// Deduplicates by name (first found wins = highest priority).
178    pub fn list(&self) -> Vec<ProfileEntry> {
179        let mut entries = Vec::new();
180        let mut seen = std::collections::HashSet::new();
181
182        for dir in &self.search_dirs {
183            if !dir.is_dir() {
184                continue;
185            }
186
187            let read_dir = match std::fs::read_dir(dir) {
188                Ok(rd) => rd,
189                Err(e) => {
190                    debug!(dir = %dir.display(), error = %e, "Failed to read profiles dir");
191                    continue;
192                }
193            };
194
195            for entry in read_dir.flatten() {
196                let path = entry.path();
197                if path.extension().and_then(|e| e.to_str()) != Some("toml") {
198                    continue;
199                }
200
201                let stem = match path.file_stem().and_then(|s| s.to_str()) {
202                    Some(s) => s.to_string(),
203                    None => continue,
204                };
205
206                if seen.contains(&stem) {
207                    continue; // Higher-priority dir already has this name
208                }
209
210                match Self::load_meta(&path) {
211                    Ok(meta) => {
212                        let profile_name = if meta.name.is_empty() {
213                            stem.clone()
214                        } else {
215                            meta.name.clone()
216                        };
217                        entries.push(ProfileEntry {
218                            name: profile_name,
219                            description: meta.description.clone(),
220                            path: path.clone(),
221                        });
222                        seen.insert(stem);
223                    }
224                    Err(e) => {
225                        debug!(
226                            path = %path.display(),
227                            error = %e,
228                            "Failed to parse profile metadata"
229                        );
230                    }
231                }
232            }
233        }
234
235        entries
236    }
237
238    /// Loads a profile by name.
239    ///
240    /// Searches all dirs in priority order for `{name}.toml`.
241    ///
242    /// # Errors
243    ///
244    /// Returns `ConfigError` if profile not found or parse fails.
245    pub fn load(&self, name: &str) -> Result<ProfileDef, ConfigError> {
246        let filename = format!("{name}.toml");
247
248        for dir in &self.search_dirs {
249            let path = dir.join(&filename);
250            if path.exists() {
251                return Self::load_from_path(&path);
252            }
253        }
254
255        Err(ConfigError::ProfileNotFound {
256            name: name.to_string(),
257            searched: self.search_dirs.clone(),
258        })
259    }
260
261    /// Loads profile metadata only (lightweight, no component settings).
262    ///
263    /// Used by [`list()`](Self::list) to avoid full deserialization.
264    fn load_meta(path: &Path) -> Result<ProfileMeta, ConfigError> {
265        #[derive(Deserialize)]
266        struct MetaOnly {
267            #[serde(default)]
268            profile: ProfileMeta,
269        }
270        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::read_file(path, e))?;
271        let meta: MetaOnly =
272            toml::from_str(&content).map_err(|e| ConfigError::parse_toml(path, e))?;
273        Ok(meta.profile)
274    }
275
276    /// Loads a profile from an explicit file path.
277    ///
278    /// # Errors
279    ///
280    /// Returns `ConfigError` if read or parse fails.
281    pub fn load_from_path(path: &Path) -> Result<ProfileDef, ConfigError> {
282        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::read_file(path, e))?;
283
284        let mut def: ProfileDef =
285            ProfileDef::from_toml(&content).map_err(|e| ConfigError::parse_toml(path, e))?;
286
287        // Default name from filename if not set in TOML
288        if def.profile.name.is_empty() {
289            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
290                def.profile.name = stem.to_string();
291            }
292        }
293
294        debug!(
295            name = %def.profile.name,
296            path = %path.display(),
297            components = def.components.len(),
298            "Loaded profile"
299        );
300
301        Ok(def)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use tempfile::TempDir;
309
310    fn write_profile(dir: &Path, name: &str, content: &str) -> PathBuf {
311        let profiles_dir = dir.join(PROFILES_DIR);
312        std::fs::create_dir_all(&profiles_dir).expect("should create profiles directory");
313        let path = profiles_dir.join(format!("{name}.toml"));
314        std::fs::write(&path, content).expect("should write profile TOML file");
315        path
316    }
317
318    #[test]
319    fn parse_minimal_profile() {
320        let toml = r#"
321[profile]
322name = "test"
323"#;
324        let def = ProfileDef::from_toml(toml).expect("should parse minimal profile TOML");
325        assert_eq!(def.name(), "test");
326        assert!(def.description().is_empty());
327        assert!(def.config.is_none());
328        assert!(def.components.is_empty());
329    }
330
331    #[test]
332    fn parse_full_profile() {
333        let toml = r#"
334[profile]
335name = "rust-dev"
336description = "Rust development mode"
337
338[config]
339debug = true
340
341[config.model]
342default = "claude-opus-4-6"
343
344[components.skill_manager]
345activate = ["rust-dev", "git-workflow"]
346deactivate = ["python-dev"]
347
348[components.agent_mgr]
349default_model = "claude-opus-4-6"
350"#;
351        let def = ProfileDef::from_toml(toml).expect("should parse full profile TOML");
352        assert_eq!(def.name(), "rust-dev");
353        assert_eq!(def.description(), "Rust development mode");
354
355        // Config override
356        let config = def
357            .config
358            .as_ref()
359            .expect("should have config section in full profile");
360        assert!(config.debug);
361        assert_eq!(config.model.default, "claude-opus-4-6");
362
363        // Component settings
364        assert_eq!(def.components.len(), 2);
365        assert!(def.components.contains_key("skill_manager"));
366        assert!(def.components.contains_key("agent_mgr"));
367
368        let sm = &def.components["skill_manager"];
369        let activate = sm
370            .get("activate")
371            .expect("should have 'activate' key in skill_manager")
372            .as_array()
373            .expect("'activate' should be an array");
374        assert_eq!(activate.len(), 2);
375    }
376
377    #[test]
378    fn profile_roundtrip() {
379        let toml = r#"
380[profile]
381name = "test"
382description = "test profile"
383"#;
384        let def = ProfileDef::from_toml(toml).expect("should parse profile for roundtrip test");
385        let serialized = def.to_toml().expect("should serialize profile to TOML");
386        let restored =
387            ProfileDef::from_toml(&serialized).expect("should deserialize roundtripped TOML");
388        assert_eq!(def.profile, restored.profile);
389    }
390
391    #[test]
392    fn store_list_profiles() {
393        let temp = TempDir::new().expect("should create temp dir for store list test");
394
395        write_profile(
396            temp.path(),
397            "alpha",
398            r#"
399[profile]
400name = "alpha"
401description = "First profile"
402"#,
403        );
404
405        write_profile(
406            temp.path(),
407            "beta",
408            r#"
409[profile]
410name = "beta"
411description = "Second profile"
412"#,
413        );
414
415        let store = ProfileStore::with_dirs(vec![temp.path().join(PROFILES_DIR)]);
416        let entries = store.list();
417
418        assert_eq!(entries.len(), 2);
419        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
420        assert!(names.contains(&"alpha"));
421        assert!(names.contains(&"beta"));
422    }
423
424    #[test]
425    fn store_load_by_name() {
426        let temp = TempDir::new().expect("should create temp dir for store load test");
427
428        write_profile(
429            temp.path(),
430            "rust-dev",
431            r#"
432[profile]
433name = "rust-dev"
434description = "Rust development"
435
436[components.skill_manager]
437activate = ["rust-dev"]
438"#,
439        );
440
441        let store = ProfileStore::with_dirs(vec![temp.path().join(PROFILES_DIR)]);
442        let def = store
443            .load("rust-dev")
444            .expect("should load rust-dev profile by name");
445
446        assert_eq!(def.name(), "rust-dev");
447        assert_eq!(def.components.len(), 1);
448    }
449
450    #[test]
451    fn store_load_not_found() {
452        let temp = TempDir::new().expect("should create temp dir for not-found test");
453        let store = ProfileStore::with_dirs(vec![temp.path().join(PROFILES_DIR)]);
454        let result = store.load("nonexistent");
455        assert!(result.is_err());
456    }
457
458    #[test]
459    fn store_priority_first_wins() {
460        let high = TempDir::new().expect("should create temp dir for high-priority profiles");
461        let low = TempDir::new().expect("should create temp dir for low-priority profiles");
462
463        write_profile(
464            high.path(),
465            "shared",
466            r#"
467[profile]
468name = "shared"
469description = "high priority"
470"#,
471        );
472
473        write_profile(
474            low.path(),
475            "shared",
476            r#"
477[profile]
478name = "shared"
479description = "low priority"
480"#,
481        );
482
483        let store = ProfileStore::with_dirs(vec![
484            high.path().join(PROFILES_DIR),
485            low.path().join(PROFILES_DIR),
486        ]);
487
488        let def = store
489            .load("shared")
490            .expect("should load shared profile from high-priority dir");
491        assert_eq!(def.description(), "high priority");
492    }
493
494    #[test]
495    fn name_defaults_to_filename() {
496        let temp = TempDir::new().expect("should create temp dir for filename-default test");
497
498        write_profile(
499            temp.path(),
500            "my-profile",
501            r#"
502[profile]
503description = "No name field"
504"#,
505        );
506
507        let store = ProfileStore::with_dirs(vec![temp.path().join(PROFILES_DIR)]);
508        let def = store
509            .load("my-profile")
510            .expect("should load profile and default name to filename");
511        assert_eq!(def.name(), "my-profile");
512    }
513
514    #[test]
515    fn component_names() {
516        let toml = r#"
517[profile]
518name = "test"
519
520[components.skill_manager]
521activate = ["a"]
522
523[components."skill::skill_manager"]
524fql_setting = true
525"#;
526        let def = ProfileDef::from_toml(toml).expect("should parse profile with component names");
527        let names = def.component_names();
528        assert_eq!(names.len(), 2);
529    }
530}