use std::path::{Path, PathBuf};
use std::sync::Arc;
use bamboo_domain::subagent::{
SubagentProfileFile, SubagentProfileRegistry, SubagentProfileRegistryError,
};
use thiserror::Error;
use super::builtin::builtin_profiles;
pub const ENV_OVERRIDE_FILE: &str = "BAMBOO_SUBAGENT_PROFILES_FILE";
pub const CONFIG_FILE_NAME: &str = "subagent_profiles.json";
#[derive(Debug, Error)]
pub enum LoaderError {
#[error("failed to read subagent profile file `{path}`: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse subagent profile file `{path}`: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(transparent)]
Registry(#[from] SubagentProfileRegistryError),
}
pub fn load_registry(
bamboo_data_dir: &Path,
workspace_dir: Option<&Path>,
) -> Result<Arc<SubagentProfileRegistry>, LoaderError> {
let mut builder = SubagentProfileRegistry::builder().extend(builtin_profiles());
let user_global = bamboo_data_dir.join(CONFIG_FILE_NAME);
if let Some(file) = read_optional(&user_global)? {
builder = builder.extend_from_file(file);
}
if let Some(ws) = workspace_dir {
let project_path = ws.join(".bamboo").join(CONFIG_FILE_NAME);
if let Some(file) = read_optional(&project_path)? {
builder = builder.extend_from_file(file);
}
}
if let Some(env_path) = env_override_path() {
let file = read_required(&env_path)?;
builder = builder.extend_from_file(file);
}
Ok(Arc::new(builder.build()?))
}
fn env_override_path() -> Option<PathBuf> {
std::env::var_os(ENV_OVERRIDE_FILE)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
fn read_optional(path: &Path) -> Result<Option<SubagentProfileFile>, LoaderError> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(parse(path, &text)?)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(LoaderError::Io {
path: path.to_path_buf(),
source: err,
}),
}
}
fn read_required(path: &Path) -> Result<SubagentProfileFile, LoaderError> {
let text = std::fs::read_to_string(path).map_err(|source| LoaderError::Io {
path: path.to_path_buf(),
source,
})?;
parse(path, &text)
}
fn parse(path: &Path, text: &str) -> Result<SubagentProfileFile, LoaderError> {
serde_json::from_str(text).map_err(|source| LoaderError::Parse {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn builtins_only_when_no_files_present() {
let dir = TempDir::new().unwrap();
let reg = load_registry(dir.path(), None).unwrap();
assert_eq!(reg.len(), 6);
assert_eq!(reg.resolve("researcher").id, "researcher");
assert_eq!(reg.resolve("does-not-exist").id, "general-purpose");
}
#[test]
fn user_global_overrides_builtin() {
let dir = TempDir::new().unwrap();
write_file(
&dir.path().join(CONFIG_FILE_NAME),
r#"{"profiles":[{
"id":"researcher",
"display_name":"Custom",
"system_prompt":"OVERRIDDEN",
"tools":{"mode":"inherit"}
}]}"#,
);
let reg = load_registry(dir.path(), None).unwrap();
let r = reg.resolve("researcher");
assert_eq!(r.display_name, "Custom");
assert_eq!(r.system_prompt, "OVERRIDDEN");
assert_eq!(reg.resolve("plan").display_name, "Planner");
}
#[test]
fn project_layer_overrides_user_global() {
let bamboo_dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
write_file(
&bamboo_dir.path().join(CONFIG_FILE_NAME),
r#"{"profiles":[{
"id":"researcher","display_name":"User-Global",
"system_prompt":"global","tools":{"mode":"inherit"}
}]}"#,
);
write_file(
&ws_dir.path().join(".bamboo").join(CONFIG_FILE_NAME),
r#"{"profiles":[{
"id":"researcher","display_name":"Project",
"system_prompt":"project","tools":{"mode":"inherit"}
}]}"#,
);
let reg = load_registry(bamboo_dir.path(), Some(ws_dir.path())).unwrap();
let r = reg.resolve("researcher");
assert_eq!(r.display_name, "Project");
assert_eq!(r.system_prompt, "project");
}
#[test]
fn env_override_takes_highest_precedence() {
let bamboo_dir = TempDir::new().unwrap();
let env_file_dir = TempDir::new().unwrap();
let env_file = env_file_dir.path().join("env_overrides.json");
write_file(
&env_file,
r#"{"profiles":[{
"id":"coder","display_name":"EnvCoder",
"system_prompt":"env","tools":{"mode":"inherit"}
}]}"#,
);
let prev = std::env::var_os(ENV_OVERRIDE_FILE);
std::env::set_var(ENV_OVERRIDE_FILE, &env_file);
let reg = load_registry(bamboo_dir.path(), None).unwrap();
match prev {
Some(v) => std::env::set_var(ENV_OVERRIDE_FILE, v),
None => std::env::remove_var(ENV_OVERRIDE_FILE),
}
assert_eq!(reg.resolve("coder").display_name, "EnvCoder");
}
#[test]
fn malformed_json_surfaces_parse_error() {
let dir = TempDir::new().unwrap();
write_file(&dir.path().join(CONFIG_FILE_NAME), "{ not json");
let err = load_registry(dir.path(), None).unwrap_err();
assert!(matches!(err, LoaderError::Parse { .. }));
}
#[test]
fn missing_user_global_file_is_silent() {
let dir = TempDir::new().unwrap();
let reg = load_registry(dir.path(), None).unwrap();
assert_eq!(reg.len(), 6);
}
}