Skip to main content

bamboo_server/subagent_profiles/
loader.rs

1//! Layered loader that composes the final [`SubagentProfileRegistry`].
2//!
3//! Layers (each later one overrides earlier entries by `id`):
4//!
5//! 1. Built-in profiles ([`super::builtin::builtin_profiles`]).
6//! 2. User global config: `<bamboo_data_dir>/subagent_profiles.json`.
7//! 3. Project-level config: `<workspace>/.bamboo/subagent_profiles.json`
8//!    (only loaded when a workspace path is provided).
9//! 4. Env override: `BAMBOO_SUBAGENT_PROFILES_FILE=<path>` if set.
10//!
11//! Missing files are silently skipped; malformed JSON returns
12//! [`LoaderError::Parse`] so misconfiguration is surfaced loudly.
13
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use bamboo_domain::subagent::{
18    SubagentProfileFile, SubagentProfileRegistry, SubagentProfileRegistryError,
19};
20use thiserror::Error;
21
22use super::builtin::builtin_profiles;
23
24/// Environment variable name for ad-hoc override files.
25pub const ENV_OVERRIDE_FILE: &str = "BAMBOO_SUBAGENT_PROFILES_FILE";
26
27/// File name used for both the user-global and project-level config layers.
28pub const CONFIG_FILE_NAME: &str = "subagent_profiles.json";
29
30#[derive(Debug, Error)]
31pub enum LoaderError {
32    #[error("failed to read subagent profile file `{path}`: {source}")]
33    Io {
34        path: PathBuf,
35        #[source]
36        source: std::io::Error,
37    },
38    #[error("failed to parse subagent profile file `{path}`: {source}")]
39    Parse {
40        path: PathBuf,
41        #[source]
42        source: serde_json::Error,
43    },
44    #[error(transparent)]
45    Registry(#[from] SubagentProfileRegistryError),
46}
47
48/// Build the merged [`SubagentProfileRegistry`].
49///
50/// `bamboo_data_dir` is the active Bamboo data directory (e.g. `~/.bamboo`).
51/// `workspace_dir` is optional; when provided, a project-level override file
52/// at `<workspace_dir>/.bamboo/subagent_profiles.json` is layered on top of
53/// the user-global one.
54pub fn load_registry(
55    bamboo_data_dir: &Path,
56    workspace_dir: Option<&Path>,
57) -> Result<Arc<SubagentProfileRegistry>, LoaderError> {
58    let mut builder = SubagentProfileRegistry::builder().extend(builtin_profiles());
59
60    // 2. User-global config.
61    let user_global = bamboo_data_dir.join(CONFIG_FILE_NAME);
62    if let Some(file) = read_optional(&user_global)? {
63        builder = builder.extend_from_file(file);
64    }
65
66    // 3. Project-level config.
67    if let Some(ws) = workspace_dir {
68        let project_path = ws.join(".bamboo").join(CONFIG_FILE_NAME);
69        if let Some(file) = read_optional(&project_path)? {
70            builder = builder.extend_from_file(file);
71        }
72    }
73
74    // 4. Environment override (always read if set; missing path is an error).
75    if let Some(env_path) = env_override_path() {
76        let file = read_required(&env_path)?;
77        builder = builder.extend_from_file(file);
78    }
79
80    Ok(Arc::new(builder.build()?))
81}
82
83fn env_override_path() -> Option<PathBuf> {
84    std::env::var_os(ENV_OVERRIDE_FILE)
85        .filter(|v| !v.is_empty())
86        .map(PathBuf::from)
87}
88
89fn read_optional(path: &Path) -> Result<Option<SubagentProfileFile>, LoaderError> {
90    match std::fs::read_to_string(path) {
91        Ok(text) => Ok(Some(parse(path, &text)?)),
92        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
93        Err(err) => Err(LoaderError::Io {
94            path: path.to_path_buf(),
95            source: err,
96        }),
97    }
98}
99
100fn read_required(path: &Path) -> Result<SubagentProfileFile, LoaderError> {
101    let text = std::fs::read_to_string(path).map_err(|source| LoaderError::Io {
102        path: path.to_path_buf(),
103        source,
104    })?;
105    parse(path, &text)
106}
107
108fn parse(path: &Path, text: &str) -> Result<SubagentProfileFile, LoaderError> {
109    serde_json::from_str(text).map_err(|source| LoaderError::Parse {
110        path: path.to_path_buf(),
111        source,
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::fs;
119    use tempfile::TempDir;
120
121    fn write_file(path: &Path, body: &str) {
122        if let Some(parent) = path.parent() {
123            fs::create_dir_all(parent).unwrap();
124        }
125        fs::write(path, body).unwrap();
126    }
127
128    #[test]
129    fn builtins_only_when_no_files_present() {
130        let dir = TempDir::new().unwrap();
131        let reg = load_registry(dir.path(), None).unwrap();
132        // Six builtins.
133        assert_eq!(reg.len(), 6);
134        assert_eq!(reg.resolve("researcher").id, "researcher");
135        // Unknown id falls back to general-purpose.
136        assert_eq!(reg.resolve("does-not-exist").id, "general-purpose");
137    }
138
139    #[test]
140    fn user_global_overrides_builtin() {
141        let dir = TempDir::new().unwrap();
142        write_file(
143            &dir.path().join(CONFIG_FILE_NAME),
144            r#"{"profiles":[{
145                "id":"researcher",
146                "display_name":"Custom",
147                "system_prompt":"OVERRIDDEN",
148                "tools":{"mode":"inherit"}
149            }]}"#,
150        );
151        let reg = load_registry(dir.path(), None).unwrap();
152        let r = reg.resolve("researcher");
153        assert_eq!(r.display_name, "Custom");
154        assert_eq!(r.system_prompt, "OVERRIDDEN");
155        // Unaffected entries remain.
156        assert_eq!(reg.resolve("plan").display_name, "Planner");
157    }
158
159    #[test]
160    fn project_layer_overrides_user_global() {
161        let bamboo_dir = TempDir::new().unwrap();
162        let ws_dir = TempDir::new().unwrap();
163
164        write_file(
165            &bamboo_dir.path().join(CONFIG_FILE_NAME),
166            r#"{"profiles":[{
167                "id":"researcher","display_name":"User-Global",
168                "system_prompt":"global","tools":{"mode":"inherit"}
169            }]}"#,
170        );
171        write_file(
172            &ws_dir.path().join(".bamboo").join(CONFIG_FILE_NAME),
173            r#"{"profiles":[{
174                "id":"researcher","display_name":"Project",
175                "system_prompt":"project","tools":{"mode":"inherit"}
176            }]}"#,
177        );
178
179        let reg = load_registry(bamboo_dir.path(), Some(ws_dir.path())).unwrap();
180        let r = reg.resolve("researcher");
181        assert_eq!(r.display_name, "Project");
182        assert_eq!(r.system_prompt, "project");
183    }
184
185    #[test]
186    fn env_override_takes_highest_precedence() {
187        // Use a unique scratch dir; the env var name is a process-global so
188        // we restore it after the test.
189        let bamboo_dir = TempDir::new().unwrap();
190        let env_file_dir = TempDir::new().unwrap();
191        let env_file = env_file_dir.path().join("env_overrides.json");
192        write_file(
193            &env_file,
194            r#"{"profiles":[{
195                "id":"coder","display_name":"EnvCoder",
196                "system_prompt":"env","tools":{"mode":"inherit"}
197            }]}"#,
198        );
199
200        // SAFETY: tests in this module run sequentially within the same
201        // process; we reset the var unconditionally below.
202        let prev = std::env::var_os(ENV_OVERRIDE_FILE);
203        std::env::set_var(ENV_OVERRIDE_FILE, &env_file);
204
205        let reg = load_registry(bamboo_dir.path(), None).unwrap();
206
207        match prev {
208            Some(v) => std::env::set_var(ENV_OVERRIDE_FILE, v),
209            None => std::env::remove_var(ENV_OVERRIDE_FILE),
210        }
211
212        assert_eq!(reg.resolve("coder").display_name, "EnvCoder");
213    }
214
215    #[test]
216    fn malformed_json_surfaces_parse_error() {
217        let dir = TempDir::new().unwrap();
218        write_file(&dir.path().join(CONFIG_FILE_NAME), "{ not json");
219        let err = load_registry(dir.path(), None).unwrap_err();
220        assert!(matches!(err, LoaderError::Parse { .. }));
221    }
222
223    #[test]
224    fn missing_user_global_file_is_silent() {
225        let dir = TempDir::new().unwrap();
226        // No file written.
227        let reg = load_registry(dir.path(), None).unwrap();
228        assert_eq!(reg.len(), 6);
229    }
230}