1use std::path::Path;
13use std::path::PathBuf;
14
15use crate::agents::planning as agent_planning;
16use crate::error::AgentConfigError;
17use crate::integration::{
18 InstallReport, InstructionSurface, Integration, McpSurface, UninstallReport,
19};
20use crate::paths;
21use crate::plan::{InstallPlan, UninstallPlan};
22use crate::scope::{Scope, ScopeKind};
23use crate::spec::{HookSpec, InstructionSpec, McpSpec};
24use crate::status::StatusReport;
25use crate::util::{instructions_dir, mcp_json_object, ownership, rules_dir};
26
27const RULES_DIR: &str = ".roo/rules";
28
29#[derive(Debug, Clone, Copy, Default)]
31pub struct RooAgent {
32 _private: (),
33}
34
35impl RooAgent {
36 pub const fn new() -> Self {
38 Self { _private: () }
39 }
40
41 fn require_local<'a>(&self, scope: &'a Scope) -> Result<&'a Path, AgentConfigError> {
42 match scope {
43 Scope::Local(p) => Ok(p),
44 Scope::Global => Err(AgentConfigError::UnsupportedScope {
45 id: "roo",
46 scope: ScopeKind::Global,
47 }),
48 }
49 }
50
51 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
52 Ok(match scope {
53 Scope::Global => paths::roo_mcp_global_file()?,
54 Scope::Local(p) => p.join(".roo").join("mcp.json"),
55 })
56 }
57}
58
59impl Integration for RooAgent {
60 fn id(&self) -> &'static str {
61 "roo"
62 }
63
64 fn display_name(&self) -> &'static str {
65 "Roo Code"
66 }
67
68 fn supported_scopes(&self) -> &'static [ScopeKind] {
69 &[ScopeKind::Local]
70 }
71
72 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
73 HookSpec::validate_tag(tag)?;
74 let root = self.require_local(scope)?;
75 let path = rules_dir::target_path(root, RULES_DIR, tag);
76 Ok(StatusReport::for_file_hook(tag, path))
77 }
78
79 fn plan_install(
80 &self,
81 scope: &Scope,
82 spec: &HookSpec,
83 ) -> Result<InstallPlan, AgentConfigError> {
84 agent_planning::rules_install(
85 Integration::id(self),
86 scope,
87 spec,
88 self.require_local(scope),
89 RULES_DIR,
90 )
91 }
92
93 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
94 agent_planning::rules_uninstall(
95 Integration::id(self),
96 scope,
97 tag,
98 self.require_local(scope),
99 RULES_DIR,
100 )
101 }
102
103 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
104 HookSpec::validate_tag(&spec.tag)?;
105 let _ = self.require_local(scope)?;
106 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
107 let rules = spec
108 .rules
109 .as_ref()
110 .ok_or(AgentConfigError::MissingSpecField {
111 id: "roo",
112 field: "rules",
113 })?;
114 rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
115 }
116
117 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
118 HookSpec::validate_tag(tag)?;
119 let _ = self.require_local(scope)?;
120 rules_dir::uninstall(scope, RULES_DIR, tag)
121 }
122}
123
124impl McpSurface for RooAgent {
125 fn id(&self) -> &'static str {
126 "roo"
127 }
128
129 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
130 &[ScopeKind::Global, ScopeKind::Local]
131 }
132
133 fn mcp_status(
134 &self,
135 scope: &Scope,
136 name: &str,
137 expected_owner: &str,
138 ) -> Result<StatusReport, AgentConfigError> {
139 McpSpec::validate_name(name)?;
140 let cfg = Self::mcp_path(scope)?;
141 let ledger = ownership::mcp_ledger_for(&cfg);
142 let presence = mcp_json_object::config_presence(&cfg, name)?;
143 let recorded = ownership::owner_of(&ledger, name)?;
144 Ok(StatusReport::for_mcp(
145 name,
146 cfg,
147 ledger,
148 presence,
149 expected_owner,
150 recorded,
151 ))
152 }
153
154 fn plan_install_mcp(
155 &self,
156 scope: &Scope,
157 spec: &McpSpec,
158 ) -> Result<InstallPlan, AgentConfigError> {
159 agent_planning::mcp_json_object_install(
160 McpSurface::id(self),
161 scope,
162 spec,
163 Self::mcp_path(scope),
164 )
165 }
166
167 fn plan_uninstall_mcp(
168 &self,
169 scope: &Scope,
170 name: &str,
171 owner_tag: &str,
172 ) -> Result<UninstallPlan, AgentConfigError> {
173 agent_planning::mcp_json_object_uninstall(
174 McpSurface::id(self),
175 scope,
176 name,
177 owner_tag,
178 Self::mcp_path(scope),
179 )
180 }
181
182 fn install_mcp(
183 &self,
184 scope: &Scope,
185 spec: &McpSpec,
186 ) -> Result<InstallReport, AgentConfigError> {
187 spec.validate()?;
188 let cfg = Self::mcp_path(scope)?;
189 spec.validate_local_secret_policy(scope)?;
190 scope.ensure_contained(&cfg)?;
191 let ledger = ownership::mcp_ledger_for(&cfg);
192 mcp_json_object::install(&cfg, &ledger, spec)
193 }
194
195 fn uninstall_mcp(
196 &self,
197 scope: &Scope,
198 name: &str,
199 owner_tag: &str,
200 ) -> Result<UninstallReport, AgentConfigError> {
201 McpSpec::validate_name(name)?;
202 HookSpec::validate_tag(owner_tag)?;
203 let cfg = Self::mcp_path(scope)?;
204 scope.ensure_contained(&cfg)?;
205 let ledger = ownership::mcp_ledger_for(&cfg);
206 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
207 }
208}
209
210impl RooAgent {
211 fn standalone_layout(
212 &self,
213 scope: &Scope,
214 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
215 let root = self.require_local(scope)?;
216 Ok(instructions_dir::StandaloneLayout {
217 config_dir: root.join(".roo"),
218 instruction_dir: root.join(".roo/rules"),
219 })
220 }
221}
222
223impl InstructionSurface for RooAgent {
224 fn id(&self) -> &'static str {
225 "roo"
226 }
227
228 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
229 &[ScopeKind::Local]
230 }
231
232 fn instruction_status(
233 &self,
234 scope: &Scope,
235 name: &str,
236 expected_owner: &str,
237 ) -> Result<StatusReport, AgentConfigError> {
238 instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
239 }
240
241 fn plan_install_instruction(
242 &self,
243 scope: &Scope,
244 spec: &InstructionSpec,
245 ) -> Result<InstallPlan, AgentConfigError> {
246 instructions_dir::standalone_plan_install(
247 InstructionSurface::id(self),
248 scope,
249 self.standalone_layout(scope),
250 spec,
251 )
252 }
253
254 fn plan_uninstall_instruction(
255 &self,
256 scope: &Scope,
257 name: &str,
258 owner_tag: &str,
259 ) -> Result<UninstallPlan, AgentConfigError> {
260 instructions_dir::standalone_plan_uninstall(
261 InstructionSurface::id(self),
262 scope,
263 self.standalone_layout(scope),
264 name,
265 owner_tag,
266 )
267 }
268
269 fn install_instruction(
270 &self,
271 scope: &Scope,
272 spec: &InstructionSpec,
273 ) -> Result<InstallReport, AgentConfigError> {
274 instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
275 }
276
277 fn uninstall_instruction(
278 &self,
279 scope: &Scope,
280 name: &str,
281 owner_tag: &str,
282 ) -> Result<UninstallReport, AgentConfigError> {
283 instructions_dir::standalone_uninstall(
284 scope,
285 self.standalone_layout(scope)?,
286 name,
287 owner_tag,
288 )
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use serde_json::{json, Value};
296 use tempfile::tempdir;
297
298 fn rules_spec(tag: &str, body: &str) -> HookSpec {
299 HookSpec::builder(tag)
300 .command_program("noop", [] as [&str; 0])
301 .rules(body)
302 .build()
303 }
304
305 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
306 McpSpec::builder(name)
307 .owner(owner)
308 .stdio("npx", ["-y", "@example/server"])
309 .build()
310 }
311
312 fn read_json(p: &Path) -> Value {
313 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
314 }
315
316 #[test]
317 fn install_rules_writes_dot_roo_rules() {
318 let dir = tempdir().unwrap();
319 let agent = RooAgent::new();
320 let scope = Scope::Local(dir.path().to_path_buf());
321 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
322 assert!(dir.path().join(".roo/rules/alpha.md").exists());
323 }
324
325 #[test]
326 fn install_mcp_writes_project_mcp_json() {
327 let dir = tempdir().unwrap();
328 let agent = RooAgent::new();
329 let scope = Scope::Local(dir.path().to_path_buf());
330 agent
331 .install_mcp(&scope, &mcp_spec("github", "myapp"))
332 .unwrap();
333 let cfg = dir.path().join(".roo/mcp.json");
334 let v = read_json(&cfg);
335 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
336 }
337
338 #[test]
339 fn install_mcp_idempotent() {
340 let dir = tempdir().unwrap();
341 let agent = RooAgent::new();
342 let scope = Scope::Local(dir.path().to_path_buf());
343 let s = mcp_spec("github", "myapp");
344 agent.install_mcp(&scope, &s).unwrap();
345 let r = agent.install_mcp(&scope, &s).unwrap();
346 assert!(r.already_installed);
347 }
348
349 #[test]
350 fn uninstall_mcp_owner_mismatch_refused() {
351 let dir = tempdir().unwrap();
352 let agent = RooAgent::new();
353 let scope = Scope::Local(dir.path().to_path_buf());
354 agent
355 .install_mcp(&scope, &mcp_spec("github", "appA"))
356 .unwrap();
357 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
358 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
359 }
360}
361
362#[cfg(test)]
363mod instruction_tests {
364 use super::*;
365 use crate::integration::InstructionSurface;
366 use crate::spec::InstructionPlacement;
367 use tempfile::tempdir;
368
369 fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
370 InstructionSpec::builder(name)
371 .owner(owner)
372 .placement(InstructionPlacement::StandaloneFile)
373 .body("# Test instruction\n")
374 .build()
375 }
376
377 #[test]
378 fn instruction_writes_to_rules_dir() {
379 let dir = tempdir().unwrap();
380 let agent = RooAgent::new();
381 let scope = Scope::Local(dir.path().to_path_buf());
382 agent
383 .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
384 .unwrap();
385 assert!(dir.path().join(".roo/rules/test-rule.md").exists());
386 }
387
388 #[test]
389 fn instruction_uninstall_removes_file() {
390 let dir = tempdir().unwrap();
391 let agent = RooAgent::new();
392 let scope = Scope::Local(dir.path().to_path_buf());
393 agent
394 .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
395 .unwrap();
396 agent
397 .uninstall_instruction(&scope, "test-rule", "myapp")
398 .unwrap();
399 assert!(!dir.path().join(".roo/rules/test-rule.md").exists());
400 }
401
402 #[test]
403 fn instruction_rejects_global_scope() {
404 let agent = RooAgent::new();
405 let spec = instruction_spec("test-rule", "myapp");
406 let plan = agent
407 .plan_install_instruction(&Scope::Global, &spec)
408 .expect("plan should refuse, not error");
409 assert_eq!(plan.status, crate::plan::PlanStatus::Refused);
410 }
411}