1use super::ConfigError;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36use tracing::debug;
37
38pub const PROFILES_DIR: &str = "profiles";
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
46#[serde(default)]
47pub struct ProfileDef {
48 pub profile: ProfileMeta,
50
51 pub config: Option<super::OrcsConfig>,
56
57 #[serde(default)]
69 pub components: HashMap<String, toml::Table>,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
74#[serde(default)]
75pub struct ProfileMeta {
76 pub name: String,
78
79 #[serde(default)]
81 pub description: String,
82}
83
84impl ProfileDef {
85 pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
91 toml::from_str(toml_str)
92 }
93
94 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
100 toml::to_string_pretty(self)
101 }
102
103 #[must_use]
105 pub fn name(&self) -> &str {
106 &self.profile.name
107 }
108
109 #[must_use]
111 pub fn description(&self) -> &str {
112 &self.profile.description
113 }
114
115 #[must_use]
117 pub fn component_names(&self) -> Vec<&str> {
118 self.components.keys().map(|k| k.as_str()).collect()
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct ProfileEntry {
125 pub name: String,
127 pub description: String,
129 pub path: PathBuf,
131}
132
133#[derive(Debug, Clone)]
141pub struct ProfileStore {
142 search_dirs: Vec<PathBuf>,
144}
145
146impl ProfileStore {
147 #[must_use]
152 pub fn new(project_root: Option<&Path>) -> Self {
153 let mut dirs = Vec::new();
154
155 if let Some(root) = project_root {
157 dirs.push(root.join(".orcs").join(PROFILES_DIR));
158 }
159
160 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 #[must_use]
170 pub fn with_dirs(dirs: Vec<PathBuf>) -> Self {
171 Self { search_dirs: dirs }
172 }
173
174 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; }
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 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 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 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 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 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 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}