use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileEntry {
pub name: String,
pub description: Option<String>,
pub path: String,
pub active: bool,
#[serde(default)]
pub installed_at: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileRegistry {
#[serde(default)]
pub profiles: BTreeMap<String, ProfileEntry>,
}
impl ProfileRegistry {
fn registry_path() -> PathBuf {
home_dir().join(".roboticus").join("profiles.toml")
}
pub fn load() -> Result<Self> {
let path = Self::registry_path();
if !path.exists() {
return Ok(Self::default_registry());
}
let contents = std::fs::read_to_string(&path).map_err(|e| {
RoboticusError::Config(format!(
"failed to read profiles.toml at {}: {e}",
path.display()
))
})?;
let registry: Self = toml::from_str(&contents).map_err(|e| {
RoboticusError::Config(format!(
"failed to parse profiles.toml at {}: {e}",
path.display()
))
})?;
Ok(registry)
}
pub fn save(&self) -> Result<()> {
let path = Self::registry_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
RoboticusError::Config(format!(
"failed to create directory {}: {e}",
parent.display()
))
})?;
}
let contents = toml::to_string_pretty(self).map_err(|e| {
RoboticusError::Config(format!("failed to serialise profiles.toml: {e}"))
})?;
std::fs::write(&path, contents).map_err(|e| {
RoboticusError::Config(format!(
"failed to write profiles.toml at {}: {e}",
path.display()
))
})?;
Ok(())
}
pub fn active_profile(&self) -> Option<(&str, &ProfileEntry)> {
self.profiles
.iter()
.find(|(_, e)| e.active)
.map(|(id, e)| (id.as_str(), e))
}
pub fn resolve_config_dir(&self, profile_id: &str) -> Result<PathBuf> {
let roboticus_root = home_dir().join(".roboticus");
if profile_id == "default" {
return Ok(roboticus_root);
}
let entry = self.profiles.get(profile_id).ok_or_else(|| {
RoboticusError::Config(format!("profile not found: {profile_id}"))
})?;
if entry.path.is_empty() {
Ok(roboticus_root)
} else {
Ok(roboticus_root.join(&entry.path))
}
}
pub fn list(&self) -> Vec<(String, ProfileEntry)> {
self.profiles
.iter()
.map(|(id, e)| (id.clone(), e.clone()))
.collect()
}
fn default_registry() -> Self {
let mut profiles = BTreeMap::new();
profiles.insert(
"default".to_string(),
ProfileEntry {
name: "Default".to_string(),
description: Some("Default agent profile".to_string()),
path: String::new(),
active: true,
installed_at: None,
version: None,
source: None,
},
);
Self { profiles }
}
pub fn ensure_profile_dir(&self, profile_id: &str) -> Result<PathBuf> {
let dir = self.resolve_config_dir(profile_id)?;
for sub in &["workspace", "skills"] {
let sub_dir = dir.join(sub);
std::fs::create_dir_all(&sub_dir).map_err(|e| {
RoboticusError::Config(format!(
"failed to create profile directory {}: {e}",
sub_dir.display()
))
})?;
}
Ok(dir)
}
}
pub fn resolve_profile_config_path(profile_override: Option<&str>) -> Option<PathBuf> {
let registry = ProfileRegistry::load().unwrap_or_default();
let profile_id = profile_override
.map(|s| s.to_string())
.or_else(|| {
registry
.active_profile()
.map(|(id, _)| id.to_string())
})
.unwrap_or_else(|| "default".to_string());
let config_dir = registry
.resolve_config_dir(&profile_id)
.unwrap_or_else(|_| home_dir().join(".roboticus"));
let candidate = config_dir.join("roboticus.toml");
if candidate.exists() {
Some(candidate)
} else {
None
}
}
#[cfg(test)]
mod profile_tests {
use super::*;
#[test]
fn default_registry_has_active_default_profile() {
let reg = ProfileRegistry::default_registry();
let (id, entry) = reg.active_profile().expect("should have active profile");
assert_eq!(id, "default");
assert!(entry.active);
assert!(entry.path.is_empty());
}
#[test]
fn resolve_config_dir_default_returns_roboticus_root() {
let reg = ProfileRegistry::default_registry();
let dir = reg.resolve_config_dir("default").unwrap();
assert!(dir.ends_with(".roboticus"));
}
#[test]
fn resolve_config_dir_custom_profile_appends_path() {
let mut reg = ProfileRegistry::default();
reg.profiles.insert(
"narrator".to_string(),
ProfileEntry {
name: "The Narrator".to_string(),
description: Some("GM profile".to_string()),
path: "profiles/narrator".to_string(),
active: false,
installed_at: None,
version: None,
source: Some("manual".to_string()),
},
);
let dir = reg.resolve_config_dir("narrator").unwrap();
assert!(dir.ends_with("profiles/narrator"));
}
#[test]
fn resolve_config_dir_unknown_profile_errors() {
let reg = ProfileRegistry::default_registry();
let result = reg.resolve_config_dir("nonexistent");
assert!(result.is_err());
}
#[test]
fn list_returns_all_profiles_sorted() {
let mut reg = ProfileRegistry::default_registry();
reg.profiles.insert(
"beta".to_string(),
ProfileEntry {
name: "Beta".to_string(),
description: None,
path: "profiles/beta".to_string(),
active: false,
installed_at: None,
version: None,
source: None,
},
);
let listing = reg.list();
assert_eq!(listing.len(), 2);
assert_eq!(listing[0].0, "beta");
assert_eq!(listing[1].0, "default");
}
#[test]
fn active_profile_returns_none_when_no_active() {
let mut reg = ProfileRegistry::default_registry();
for entry in reg.profiles.values_mut() {
entry.active = false;
}
assert!(reg.active_profile().is_none());
}
#[test]
fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("profiles.toml");
let mut reg = ProfileRegistry::default_registry();
reg.profiles.insert(
"test".to_string(),
ProfileEntry {
name: "Test Profile".to_string(),
description: Some("for testing".to_string()),
path: "profiles/test".to_string(),
active: false,
installed_at: Some("2026-03-28".to_string()),
version: Some("0.11.0".to_string()),
source: Some("registry".to_string()),
},
);
let contents = toml::to_string_pretty(®).unwrap();
std::fs::write(&path, &contents).unwrap();
let loaded: ProfileRegistry =
toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.profiles.len(), 2);
let test_entry = loaded.profiles.get("test").unwrap();
assert_eq!(test_entry.name, "Test Profile");
assert_eq!(test_entry.source.as_deref(), Some("registry"));
}
#[test]
fn ensure_profile_dir_creates_subdirs() {
let dir = tempfile::tempdir().unwrap();
let profile_dir = dir.path().join("profiles").join("gm");
let mut reg = ProfileRegistry::default();
reg.profiles.insert(
"gm".to_string(),
ProfileEntry {
name: "GM".to_string(),
description: None,
path: profile_dir.to_string_lossy().to_string(),
active: true,
installed_at: None,
version: None,
source: None,
},
);
std::fs::create_dir_all(&profile_dir).unwrap();
for sub in &["workspace", "skills"] {
let sub_dir = profile_dir.join(sub);
std::fs::create_dir_all(&sub_dir).unwrap();
assert!(sub_dir.exists());
}
}
#[test]
fn empty_path_profile_resolves_to_root() {
let mut reg = ProfileRegistry::default();
reg.profiles.insert(
"legacy".to_string(),
ProfileEntry {
name: "Legacy".to_string(),
description: None,
path: String::new(),
active: false,
installed_at: None,
version: None,
source: None,
},
);
let dir = reg.resolve_config_dir("legacy").unwrap();
assert!(dir.ends_with(".roboticus"));
}
}