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 let rules = spec
107 .rules
108 .as_ref()
109 .ok_or(AgentConfigError::MissingSpecField {
110 id: "roo",
111 field: "rules",
112 })?;
113 rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
114 }
115
116 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
117 HookSpec::validate_tag(tag)?;
118 let _ = self.require_local(scope)?;
119 rules_dir::uninstall(scope, RULES_DIR, tag)
120 }
121}
122
123impl McpSurface for RooAgent {
124 fn id(&self) -> &'static str {
125 "roo"
126 }
127
128 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
129 &[ScopeKind::Global, ScopeKind::Local]
130 }
131
132 fn mcp_status(
133 &self,
134 scope: &Scope,
135 name: &str,
136 expected_owner: &str,
137 ) -> Result<StatusReport, AgentConfigError> {
138 McpSpec::validate_name(name)?;
139 let cfg = Self::mcp_path(scope)?;
140 let ledger = ownership::mcp_ledger_for(&cfg);
141 let presence = mcp_json_object::config_presence(&cfg, name)?;
142 let recorded = ownership::owner_of(&ledger, name)?;
143 Ok(StatusReport::for_mcp(
144 name,
145 cfg,
146 ledger,
147 presence,
148 expected_owner,
149 recorded,
150 ))
151 }
152
153 fn plan_install_mcp(
154 &self,
155 scope: &Scope,
156 spec: &McpSpec,
157 ) -> Result<InstallPlan, AgentConfigError> {
158 agent_planning::mcp_json_object_install(
159 McpSurface::id(self),
160 scope,
161 spec,
162 Self::mcp_path(scope),
163 )
164 }
165
166 fn plan_uninstall_mcp(
167 &self,
168 scope: &Scope,
169 name: &str,
170 owner_tag: &str,
171 ) -> Result<UninstallPlan, AgentConfigError> {
172 agent_planning::mcp_json_object_uninstall(
173 McpSurface::id(self),
174 scope,
175 name,
176 owner_tag,
177 Self::mcp_path(scope),
178 )
179 }
180
181 fn install_mcp(
182 &self,
183 scope: &Scope,
184 spec: &McpSpec,
185 ) -> Result<InstallReport, AgentConfigError> {
186 spec.validate()?;
187 let cfg = Self::mcp_path(scope)?;
188 spec.validate_local_secret_policy(scope)?;
189 scope.ensure_contained(&cfg)?;
190 let ledger = ownership::mcp_ledger_for(&cfg);
191 mcp_json_object::install(&cfg, &ledger, spec)
192 }
193
194 fn uninstall_mcp(
195 &self,
196 scope: &Scope,
197 name: &str,
198 owner_tag: &str,
199 ) -> Result<UninstallReport, AgentConfigError> {
200 McpSpec::validate_name(name)?;
201 HookSpec::validate_tag(owner_tag)?;
202 let cfg = Self::mcp_path(scope)?;
203 scope.ensure_contained(&cfg)?;
204 let ledger = ownership::mcp_ledger_for(&cfg);
205 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
206 }
207}
208
209impl RooAgent {
210 fn standalone_layout(
211 &self,
212 scope: &Scope,
213 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
214 let root = self.require_local(scope)?;
215 Ok(instructions_dir::StandaloneLayout {
216 config_dir: root.join(".roo"),
217 instruction_dir: root.join(".roo/rules"),
218 })
219 }
220}
221
222impl InstructionSurface for RooAgent {
223 fn id(&self) -> &'static str {
224 "roo"
225 }
226
227 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
228 &[ScopeKind::Local]
229 }
230
231 fn instruction_status(
232 &self,
233 scope: &Scope,
234 name: &str,
235 expected_owner: &str,
236 ) -> Result<StatusReport, AgentConfigError> {
237 instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
238 }
239
240 fn plan_install_instruction(
241 &self,
242 scope: &Scope,
243 spec: &InstructionSpec,
244 ) -> Result<InstallPlan, AgentConfigError> {
245 instructions_dir::standalone_plan_install(
246 InstructionSurface::id(self),
247 scope,
248 self.standalone_layout(scope),
249 spec,
250 )
251 }
252
253 fn plan_uninstall_instruction(
254 &self,
255 scope: &Scope,
256 name: &str,
257 owner_tag: &str,
258 ) -> Result<UninstallPlan, AgentConfigError> {
259 instructions_dir::standalone_plan_uninstall(
260 InstructionSurface::id(self),
261 scope,
262 self.standalone_layout(scope),
263 name,
264 owner_tag,
265 )
266 }
267
268 fn install_instruction(
269 &self,
270 scope: &Scope,
271 spec: &InstructionSpec,
272 ) -> Result<InstallReport, AgentConfigError> {
273 instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
274 }
275
276 fn uninstall_instruction(
277 &self,
278 scope: &Scope,
279 name: &str,
280 owner_tag: &str,
281 ) -> Result<UninstallReport, AgentConfigError> {
282 instructions_dir::standalone_uninstall(
283 scope,
284 self.standalone_layout(scope)?,
285 name,
286 owner_tag,
287 )
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use serde_json::{json, Value};
295 use tempfile::tempdir;
296
297 fn rules_spec(tag: &str, body: &str) -> HookSpec {
298 HookSpec::builder(tag)
299 .command_program("noop", [] as [&str; 0])
300 .rules(body)
301 .build()
302 }
303
304 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
305 McpSpec::builder(name)
306 .owner(owner)
307 .stdio("npx", ["-y", "@example/server"])
308 .build()
309 }
310
311 fn read_json(p: &Path) -> Value {
312 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
313 }
314
315 #[test]
316 fn install_rules_writes_dot_roo_rules() {
317 let dir = tempdir().unwrap();
318 let agent = RooAgent::new();
319 let scope = Scope::Local(dir.path().to_path_buf());
320 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
321 assert!(dir.path().join(".roo/rules/alpha.md").exists());
322 }
323
324 #[test]
325 fn install_mcp_writes_project_mcp_json() {
326 let dir = tempdir().unwrap();
327 let agent = RooAgent::new();
328 let scope = Scope::Local(dir.path().to_path_buf());
329 agent
330 .install_mcp(&scope, &mcp_spec("github", "myapp"))
331 .unwrap();
332 let cfg = dir.path().join(".roo/mcp.json");
333 let v = read_json(&cfg);
334 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
335 }
336
337 #[test]
338 fn install_mcp_idempotent() {
339 let dir = tempdir().unwrap();
340 let agent = RooAgent::new();
341 let scope = Scope::Local(dir.path().to_path_buf());
342 let s = mcp_spec("github", "myapp");
343 agent.install_mcp(&scope, &s).unwrap();
344 let r = agent.install_mcp(&scope, &s).unwrap();
345 assert!(r.already_installed);
346 }
347
348 #[test]
349 fn uninstall_mcp_owner_mismatch_refused() {
350 let dir = tempdir().unwrap();
351 let agent = RooAgent::new();
352 let scope = Scope::Local(dir.path().to_path_buf());
353 agent
354 .install_mcp(&scope, &mcp_spec("github", "appA"))
355 .unwrap();
356 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
357 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
358 }
359}
360
361#[cfg(test)]
362mod instruction_tests {
363 use super::*;
364 use crate::integration::InstructionSurface;
365 use crate::spec::InstructionPlacement;
366 use tempfile::tempdir;
367
368 fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
369 InstructionSpec::builder(name)
370 .owner(owner)
371 .placement(InstructionPlacement::StandaloneFile)
372 .body("# Test instruction\n")
373 .build()
374 }
375
376 #[test]
377 fn instruction_writes_to_rules_dir() {
378 let dir = tempdir().unwrap();
379 let agent = RooAgent::new();
380 let scope = Scope::Local(dir.path().to_path_buf());
381 agent
382 .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
383 .unwrap();
384 assert!(dir.path().join(".roo/rules/test-rule.md").exists());
385 }
386
387 #[test]
388 fn instruction_uninstall_removes_file() {
389 let dir = tempdir().unwrap();
390 let agent = RooAgent::new();
391 let scope = Scope::Local(dir.path().to_path_buf());
392 agent
393 .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
394 .unwrap();
395 agent
396 .uninstall_instruction(&scope, "test-rule", "myapp")
397 .unwrap();
398 assert!(!dir.path().join(".roo/rules/test-rule.md").exists());
399 }
400
401 #[test]
402 fn instruction_rejects_global_scope() {
403 let agent = RooAgent::new();
404 let spec = instruction_spec("test-rule", "myapp");
405 let plan = agent
406 .plan_install_instruction(&Scope::Global, &spec)
407 .expect("plan should refuse, not error");
408 assert_eq!(plan.status, crate::plan::PlanStatus::Refused);
409 }
410}