roboticus-core 0.11.2

Shared types, config parsing, personality system, and error types for the Roboticus agent runtime
Documentation
// Note: this file is included into config.rs via include!(), so all imports from
// config.rs (std::path, serde, crate::error) are already in scope.
use std::collections::BTreeMap;

/// A single entry in the profile registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileEntry {
    pub name: String,
    pub description: Option<String>,
    /// Path relative to `~/.roboticus/`. Empty string means the root itself (the default profile).
    pub path: String,
    pub active: bool,
    #[serde(default)]
    pub installed_at: Option<String>,
    #[serde(default)]
    pub version: Option<String>,
    /// One of "registry", "local", "manual".
    #[serde(default)]
    pub source: Option<String>,
}

/// The `~/.roboticus/profiles.toml` file structure.
#[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")
    }

    /// Load from `~/.roboticus/profiles.toml`, creating a default registry if the file is
    /// absent.  A missing file is not an error — it simply means the user has not yet created
    /// any named profiles; we treat the `~/.roboticus/` root as the active default profile.
    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)
    }

    /// Save to `~/.roboticus/profiles.toml`.
    pub fn save(&self) -> Result<()> {
        let path = Self::registry_path();
        // Ensure parent directory exists.
        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(())
    }

    /// Return the active profile as `(id, entry)`, or `None` if no profile is marked active.
    pub fn active_profile(&self) -> Option<(&str, &ProfileEntry)> {
        self.profiles
            .iter()
            .find(|(_, e)| e.active)
            .map(|(id, e)| (id.as_str(), e))
    }

    /// Resolve the configuration directory for the given profile ID.
    ///
    /// * `"default"` (or any profile whose `path` is empty) → `~/.roboticus/`
    /// * Any other profile → `~/.roboticus/<entry.path>/`
    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))
        }
    }

    /// List all profiles as `(id, entry)` pairs in key order.
    pub fn list(&self) -> Vec<(String, ProfileEntry)> {
        self.profiles
            .iter()
            .map(|(id, e)| (id.clone(), e.clone()))
            .collect()
    }

    /// Build the fallback registry that represents an install with no `profiles.toml`.
    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 }
    }

    /// Ensure the profile directory (and standard sub-directories) exist on disk.
    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)
    }
}

/// Convenience wrapper: load the registry, resolve the active (or override) profile, and return
/// the path to that profile's `roboticus.toml`.
///
/// `profile_override` is the value of the `--profile` CLI flag, if provided.
/// Returns `None` if the config file does not exist (caller may fall back to built-in defaults).
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);
        // BTreeMap is sorted — "beta" < "default"
        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(&reg).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,
            },
        );

        // Override resolve_config_dir by testing ensure_profile_dir indirectly:
        // the subdirs workspace/ and skills/ should be created
        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"));
    }
}