roma-core 0.1.0

Core types, session, errors, and utilities for Roma Agent
Documentation
//! Canonical path resolution for ~/.roma/ runtime state.
//!
//! Extends `roma_config::Config::config_home()` with:
//! - typed path methods (skills_dir, memory_dir, sessions_dir, prompts_dir, ...)
//! - `--home` CLI flag override via `detect_with_root`
//! - idempotent `ensure_layout()` for first-run seeding
//! - `load_prompt()` for bundled-vs-user prompt fallback

use std::borrow::Cow;
use std::path::{Path, PathBuf};

/// Errors that can arise resolving or seeding the roma home layout.
#[derive(Debug, thiserror::Error)]
pub enum RomaHomeError {
    /// Neither `ROMA_HOME` nor a usable user home directory could be
    /// resolved. Direct the user to set `ROMA_HOME` explicitly.
    #[error("could not determine roma home directory; set ROMA_HOME or HOME to override")]
    NoHomeDir,
    /// An I/O error during `ensure_layout` or `load_prompt`.
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

/// Resolved roma home layout. All paths derive from `root`.
#[derive(Debug, Clone)]
pub struct RomaHome {
    root: PathBuf,
}

impl RomaHome {
    /// Detect home from environment (`ROMA_HOME` env var or default
    /// `~/.roma/`). CLI consumers should parse `--home` first and use
    /// [`Self::detect_with_root`] when the flag is present.
    pub fn detect() -> Result<Self, RomaHomeError> {
        let root = match std::env::var("ROMA_HOME") {
            Ok(path) => PathBuf::from(path),
            Err(_) => default_root()?,
        };
        Ok(Self { root })
    }

    /// Construct with an explicit root (from `--home` CLI flag or library use).
    #[must_use]
    pub fn detect_with_root(root: PathBuf) -> Self {
        Self { root }
    }

    /// The resolved root directory (`~/.roma/` or override).
    #[must_use]
    pub fn root(&self) -> &Path {
        &self.root
    }

    #[must_use]
    pub fn skills_dir(&self) -> PathBuf {
        self.root.join("skills")
    }

    #[must_use]
    pub fn memory_dir(&self) -> PathBuf {
        self.root.join("memory")
    }

    #[must_use]
    pub fn sessions_dir(&self) -> PathBuf {
        self.root.join("sessions")
    }

    #[must_use]
    pub fn prompts_dir(&self) -> PathBuf {
        self.root.join("prompts")
    }

    #[must_use]
    pub fn rules_dir(&self) -> PathBuf {
        self.root.join("rules")
    }

    #[must_use]
    pub fn worktrees_dir(&self) -> PathBuf {
        self.root.join("worktrees")
    }

    #[must_use]
    pub fn logs_dir(&self) -> PathBuf {
        self.root.join("logs")
    }

    #[must_use]
    pub fn debug_bundles_dir(&self) -> PathBuf {
        self.root.join("debug-bundles")
    }

    #[must_use]
    pub fn config_path(&self) -> PathBuf {
        self.root.join("config.toml")
    }

    #[must_use]
    pub fn env_path(&self) -> PathBuf {
        self.root.join(".env")
    }

    /// Create the full directory layout if missing. Idempotent.
    ///
    /// Does NOT overwrite existing files. Does NOT touch `config.toml` or
    /// `.env` (those are setup wizard's job).
    pub fn ensure_layout(&self) -> Result<(), RomaHomeError> {
        std::fs::create_dir_all(&self.root)?;
        for sub in [
            self.skills_dir(),
            self.memory_dir(),
            self.sessions_dir(),
            self.prompts_dir(),
            self.rules_dir(),
            self.worktrees_dir(),
            self.logs_dir(),
            self.debug_bundles_dir(),
        ] {
            std::fs::create_dir_all(&sub)?;
        }
        // Memory level subdirs (L0..L4).
        for level in ["L0", "L1", "L2", "L3", "L4"] {
            std::fs::create_dir_all(self.memory_dir().join(level))?;
        }
        Ok(())
    }

    /// Load a prompt by name. Returns user's version at `prompts/<name>.md`
    /// if present, else the bundled fallback.
    ///
    /// `bundled` is typically a `include_str!` compile-time constant.
    #[must_use]
    pub fn load_prompt<'a>(&self, name: &str, bundled: &'a str) -> Cow<'a, str> {
        let user_path = self.prompts_dir().join(format!("{name}.md"));
        match std::fs::read_to_string(&user_path) {
            Ok(content) => Cow::Owned(content),
            Err(_) => Cow::Borrowed(bundled),
        }
    }

    /// Load stream rules from `rules/`. Invalid files are skipped with a
    /// warning; a missing directory yields no rules (never fails startup).
    #[must_use]
    pub fn load_rules(&self) -> Vec<crate::rules::StreamRule> {
        crate::rules::load_rules(&self.rules_dir())
    }
}

fn default_root() -> Result<PathBuf, RomaHomeError> {
    let home = dirs::home_dir().ok_or(RomaHomeError::NoHomeDir)?;
    Ok(home.join(".roma"))
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn detect_with_root_round_trips() {
        let home = RomaHome::detect_with_root(PathBuf::from("/tmp/test-roma"));
        assert_eq!(home.root(), Path::new("/tmp/test-roma"));
    }

    #[test]
    fn paths_join_correctly() {
        let home = RomaHome::detect_with_root(PathBuf::from("/tmp/x"));
        assert_eq!(home.skills_dir(), PathBuf::from("/tmp/x/skills"));
        assert_eq!(home.memory_dir(), PathBuf::from("/tmp/x/memory"));
        assert_eq!(home.sessions_dir(), PathBuf::from("/tmp/x/sessions"));
        assert_eq!(home.prompts_dir(), PathBuf::from("/tmp/x/prompts"));
        assert_eq!(home.config_path(), PathBuf::from("/tmp/x/config.toml"));
        assert_eq!(home.env_path(), PathBuf::from("/tmp/x/.env"));
    }

    #[test]
    fn worktrees_dir_under_root() {
        let home = RomaHome::detect_with_root(PathBuf::from("/tmp/x"));
        assert_eq!(home.worktrees_dir(), PathBuf::from("/tmp/x/worktrees"));
    }

    #[test]
    fn ensure_layout_creates_all_directories() {
        let tmp = tempfile::TempDir::new().unwrap();
        let root = tmp.path().join("roma-test");
        let home = RomaHome::detect_with_root(root.clone());
        home.ensure_layout().unwrap();
        for sub in [
            "skills",
            "memory",
            "sessions",
            "prompts",
            "rules",
            "worktrees",
            "logs",
            "debug-bundles",
        ] {
            assert!(root.join(sub).is_dir(), "missing subdir: {sub}");
        }
        for level in ["L0", "L1", "L2", "L3", "L4"] {
            assert!(
                root.join("memory").join(level).is_dir(),
                "missing memory level: {level}"
            );
        }
    }

    #[test]
    fn ensure_layout_is_idempotent() {
        let tmp = tempfile::TempDir::new().unwrap();
        let home = RomaHome::detect_with_root(tmp.path().to_path_buf());
        home.ensure_layout().unwrap();
        // Second call must not error.
        home.ensure_layout().unwrap();
    }

    #[test]
    fn load_prompt_uses_bundled_when_user_file_absent() {
        let tmp = tempfile::TempDir::new().unwrap();
        let home = RomaHome::detect_with_root(tmp.path().to_path_buf());
        home.ensure_layout().unwrap();
        let loaded = home.load_prompt("test", "bundled content");
        assert_eq!(loaded, "bundled content");
    }

    #[test]
    fn load_prompt_uses_user_file_when_present() {
        let tmp = tempfile::TempDir::new().unwrap();
        let home = RomaHome::detect_with_root(tmp.path().to_path_buf());
        home.ensure_layout().unwrap();
        std::fs::write(
            home.prompts_dir().join("test.md"),
            "user customized content",
        )
        .unwrap();
        let loaded = home.load_prompt("test", "bundled content");
        assert_eq!(loaded, "user customized content");
    }

    #[test]
    fn rules_dir_and_load_rules_round_trip() {
        let tmp = tempfile::TempDir::new().unwrap();
        let home = RomaHome::detect_with_root(tmp.path().to_path_buf());
        home.ensure_layout().unwrap();
        assert!(home.rules_dir().is_dir());
        // Empty dir → no rules (never an error).
        assert!(home.load_rules().is_empty());
        std::fs::write(
            home.rules_dir().join("r.md"),
            "---\ndescription: d\nmatch: \"x\"\nscope: text\nmode: remind\n---\nbody\n",
        )
        .unwrap();
        assert_eq!(home.load_rules().len(), 1);
    }
}