Skip to main content

bamboo_engine/profiles/
loader.rs

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