use std::borrow::Cow;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum RomaHomeError {
#[error("could not determine roma home directory; set ROMA_HOME or HOME to override")]
NoHomeDir,
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct RomaHome {
root: PathBuf,
}
impl RomaHome {
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 })
}
#[must_use]
pub fn detect_with_root(root: PathBuf) -> Self {
Self { root }
}
#[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")
}
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)?;
}
for level in ["L0", "L1", "L2", "L3", "L4"] {
std::fs::create_dir_all(self.memory_dir().join(level))?;
}
Ok(())
}
#[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),
}
}
#[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();
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());
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);
}
}