use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::home_dir;
use super::types::{ConfigFile, Pool, Profile};
use crate::aliases::{Alias, is_builtin_subcommand};
pub fn user_config_path() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg).join("roba.toml"));
}
home_dir().map(|h| h.join(".config").join("roba.toml"))
}
pub fn discover_project_configs(start: &Path) -> Vec<PathBuf> {
let mut hits: Vec<PathBuf> = Vec::new();
let mut current = start.to_path_buf();
loop {
let candidate = current.join("roba.toml");
if candidate.is_file() {
hits.push(candidate);
}
let is_git_root = current.join(".git").exists();
if is_git_root {
break;
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => break,
}
}
hits.reverse();
hits
}
fn load_file(path: &Path) -> Result<ConfigFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading config at {}", path.display()))?;
parse_config_str(&content).with_context(|| format!("parsing config at {}", path.display()))
}
pub fn parse_config_str(content: &str) -> Result<ConfigFile> {
let mut value: toml::Value = toml::from_str(content).context("parsing config TOML")?;
let profile_map: HashMap<String, Profile> = if let toml::Value::Table(table) = &mut value {
match table.remove("profile") {
Some(v) => v.try_into().context("parsing [profile.*] tables")?,
None => HashMap::new(),
}
} else {
HashMap::new()
};
let alias_map: HashMap<String, Alias> = if let toml::Value::Table(table) = &mut value {
match table.remove("alias") {
Some(v) => v.try_into().context("parsing [alias.*] tables")?,
None => HashMap::new(),
}
} else {
HashMap::new()
};
let session_map: HashMap<String, String> = if let toml::Value::Table(table) = &mut value {
match table.remove("session") {
Some(v) => v.try_into().context("parsing [session] table")?,
None => HashMap::new(),
}
} else {
HashMap::new()
};
let defaults: Profile = value.try_into().context("parsing top-level keys")?;
Ok(ConfigFile {
defaults,
profile: profile_map,
alias: alias_map,
session: session_map,
})
}
pub fn load_pool_from(cwd: &Path) -> Result<Pool> {
let mut pool = Pool::default();
let mut layers: Vec<PathBuf> = Vec::new();
if let Some(user) = user_config_path()
&& user.is_file()
{
layers.push(user);
}
layers.extend(discover_project_configs(cwd));
for path in layers {
let cfg = load_file(&path)?;
pool.defaults.merge_in(cfg.defaults);
for (name, profile) in cfg.profile {
pool.profiles
.entry(name)
.or_insert_with(Profile::default)
.merge_in(profile);
}
for (name, alias) in cfg.alias {
pool.aliases.insert(name, alias);
}
for (name, uuid) in cfg.session {
pool.sessions.insert(name, uuid);
}
pool.sources.push(path);
}
warn_on_shadowed_aliases(&pool);
Ok(pool)
}
fn warn_on_shadowed_aliases(pool: &Pool) {
for name in shadowed_alias_names(pool) {
eprintln!(
"warning: alias `{name}` is shadowed by the built-in `{name}` subcommand; rename it to use this alias"
);
}
}
fn shadowed_alias_names(pool: &Pool) -> Vec<&String> {
let mut shadowed: Vec<&String> = pool
.aliases
.keys()
.filter(|n| is_builtin_subcommand(n))
.collect();
shadowed.sort();
shadowed
}
pub fn load_pool() -> Result<Pool> {
let cwd = std::env::current_dir().context("getting current dir")?;
load_pool_from(&cwd)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_tmp(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "{content}").unwrap();
f.flush().unwrap();
f
}
fn write_file(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, content).unwrap();
}
#[test]
fn shadowed_aliases_track_the_real_subcommand_set() {
let mut pool = Pool::default();
for name in ["show", "doctor", "skill", "agent", "my-verb"] {
pool.aliases.insert(name.to_string(), Alias::default());
}
let shadowed: Vec<&str> = shadowed_alias_names(&pool)
.iter()
.map(|s| s.as_str())
.collect();
assert!(shadowed.contains(&"show"));
assert!(shadowed.contains(&"doctor"));
assert!(!shadowed.contains(&"skill"));
assert!(!shadowed.contains(&"agent"));
assert!(!shadowed.contains(&"my-verb"));
}
#[test]
fn discover_finds_roba_toml_in_starting_dir() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "roba.toml", "");
let found = discover_project_configs(tmp.path());
assert_eq!(found, vec![tmp.path().join("roba.toml")]);
}
#[test]
fn discover_walks_up_collecting_all_hits() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(tmp.path(), "repo/roba.toml", "");
write_file(tmp.path(), "repo/sub/roba.toml", "");
write_file(tmp.path(), "repo/sub/deeper/.gitkeep", "");
let found = discover_project_configs(&tmp.path().join("repo/sub/deeper"));
assert_eq!(
found,
vec![
tmp.path().join("repo/roba.toml"),
tmp.path().join("repo/sub/roba.toml"),
]
);
}
#[test]
fn discover_stops_at_git_root() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "roba.toml", "");
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(tmp.path(), "repo/sub/.gitkeep", "");
let found = discover_project_configs(&tmp.path().join("repo/sub"));
assert!(found.is_empty());
}
#[test]
fn discover_finds_at_git_root_itself() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(tmp.path(), "repo/roba.toml", "");
write_file(tmp.path(), "repo/sub/.gitkeep", "");
let found = discover_project_configs(&tmp.path().join("repo/sub"));
assert_eq!(found, vec![tmp.path().join("repo/roba.toml")]);
}
#[test]
fn discover_returns_empty_when_no_file_anywhere() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "a/b/c/.gitkeep", "");
let found = discover_project_configs(&tmp.path().join("a/b/c"));
assert!(found.is_empty());
}
#[test]
fn load_file_splits_defaults_and_profiles() {
let f = write_tmp(
r#"
readonly = true
[profile.x]
git_diff = true
"#,
);
let cfg = load_file(f.path()).unwrap();
assert_eq!(cfg.defaults.readonly, Some(true));
assert_eq!(cfg.profile["x"].git_diff, Some(true));
}
#[test]
fn load_file_errors_on_typo_top_level() {
let f = write_tmp("readonlyz = true\n");
let err = load_file(f.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("parsing top-level keys"));
}
#[test]
fn pool_walkup_merges_top_level_defaults() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(
tmp.path(),
"repo/roba.toml",
r#"
readonly = true
allow_tool = ["Bash(git status)"]
"#,
);
write_file(
tmp.path(),
"repo/sub/roba.toml",
r#"
readonly = false
allow_tool = ["Bash(git diff)"]
"#,
);
write_file(tmp.path(), "repo/sub/inner/.gitkeep", "");
let pool = load_pool_from(&tmp.path().join("repo/sub/inner")).unwrap();
assert_eq!(pool.defaults.readonly, Some(false));
assert_eq!(
pool.defaults.allow_tool,
vec!["Bash(git status)".to_string(), "Bash(git diff)".to_string()]
);
}
#[test]
fn pool_parses_session_bindings() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(
tmp.path(),
"repo/roba.toml",
r#"
[session]
meta = "0199aabb-ccdd"
worktree-a = "0199eeff-0011"
"#,
);
write_file(tmp.path(), "repo/sub/.gitkeep", "");
let pool = load_pool_from(&tmp.path().join("repo/sub")).unwrap();
assert_eq!(
pool.sessions.get("meta").map(String::as_str),
Some("0199aabb-ccdd")
);
assert_eq!(
pool.sessions.get("worktree-a").map(String::as_str),
Some("0199eeff-0011")
);
}
#[test]
fn pool_walkup_session_closest_wins() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(
tmp.path(),
"repo/roba.toml",
r#"
[session]
meta = "far-uuid"
only-far = "far-only-uuid"
"#,
);
write_file(
tmp.path(),
"repo/sub/roba.toml",
r#"
[session]
meta = "near-uuid"
only-near = "near-only-uuid"
"#,
);
write_file(tmp.path(), "repo/sub/inner/.gitkeep", "");
let pool = load_pool_from(&tmp.path().join("repo/sub/inner")).unwrap();
assert_eq!(
pool.sessions.get("meta").map(String::as_str),
Some("near-uuid")
);
assert_eq!(
pool.sessions.get("only-far").map(String::as_str),
Some("far-only-uuid")
);
assert_eq!(
pool.sessions.get("only-near").map(String::as_str),
Some("near-only-uuid")
);
}
#[test]
fn pool_walkup_merges_named_profile_across_files() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "repo/.git/HEAD", "");
write_file(
tmp.path(),
"repo/roba.toml",
r#"
[profile.review]
readonly = true
prepend = ["/farther.md"]
"#,
);
write_file(
tmp.path(),
"repo/sub/roba.toml",
r#"
[profile.review]
git_diff = true
prepend = ["/closer.md"]
"#,
);
write_file(tmp.path(), "repo/sub/inner/.gitkeep", "");
let pool = load_pool_from(&tmp.path().join("repo/sub/inner")).unwrap();
let p = pool.get("review").unwrap();
assert_eq!(p.readonly, Some(true));
assert_eq!(p.git_diff, Some(true));
assert_eq!(
p.prepend,
vec![PathBuf::from("/farther.md"), PathBuf::from("/closer.md")]
);
}
}