bamboo_engine/profiles/
loader.rs1use 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
28pub const ENV_OVERRIDE_FILE: &str = "BAMBOO_SUBAGENT_PROFILES_FILE";
30
31pub 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
52pub 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 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 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 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 assert_eq!(reg.len(), 6);
138 assert_eq!(reg.resolve("researcher").id, "researcher");
139 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 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 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 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 let reg = load_registry(dir.path(), None).unwrap();
232 assert_eq!(reg.len(), 6);
233 }
234}