1use std::path::PathBuf;
21
22use serde_json::json;
23
24use crate::agents::planning as agent_planning;
25use crate::error::AgentConfigError;
26use crate::integration::{
27 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
28};
29use crate::paths;
30use crate::plan::{
31 has_refusal, InstallPlan, PlanTarget as DryPlanTarget, RefusalReason, UninstallPlan,
32};
33use crate::scope::{Scope, ScopeKind};
34use crate::spec::{Event, HookSpec, InstructionSpec, McpSpec, SkillSpec};
35use crate::status::{ConfigPresence, InstallStatus, PathStatus, PlanTarget, StatusReport};
36use crate::util::{
37 file_lock, instructions_dir, json_patch, mcp_json_object, ownership, planning, rules_dir,
38 safe_fs, skills_dir,
39};
40
41const RULES_DIR: &str = ".windsurf/rules";
42
43#[derive(Debug, Clone, Copy, Default)]
45pub struct WindsurfAgent {
46 _private: (),
47}
48
49impl WindsurfAgent {
50 pub const fn new() -> Self {
52 Self { _private: () }
53 }
54
55 fn project_root<'a>(&self, scope: &'a Scope) -> Result<&'a std::path::Path, AgentConfigError> {
56 match scope {
57 Scope::Local(p) => Ok(p),
58 Scope::Global => Err(AgentConfigError::UnsupportedScope {
59 id: "windsurf",
60 scope: ScopeKind::Global,
61 }),
62 }
63 }
64
65 fn hooks_path(&self, scope: &Scope) -> Result<PathBuf, AgentConfigError> {
66 Ok(self.project_root(scope)?.join(".windsurf/hooks.json"))
67 }
68
69 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
70 Ok(match scope {
71 Scope::Global => paths::windsurf_mcp_global_file()?,
72 Scope::Local(p) => p.join(".windsurf/mcp_config.json"),
73 })
74 }
75
76 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
77 Ok(match scope {
78 Scope::Global => paths::home_dir()?
79 .join(".codeium")
80 .join("windsurf")
81 .join("skills"),
82 Scope::Local(p) => p.join(".windsurf").join("skills"),
83 })
84 }
85}
86
87impl Integration for WindsurfAgent {
88 fn id(&self) -> &'static str {
89 "windsurf"
90 }
91
92 fn display_name(&self) -> &'static str {
93 "Windsurf"
94 }
95
96 fn supported_scopes(&self) -> &'static [ScopeKind] {
97 &[ScopeKind::Local]
98 }
99
100 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
101 HookSpec::validate_tag(tag)?;
102 let root = self.project_root(scope)?;
103 let rules_file = rules_dir::target_path(root, RULES_DIR, tag);
104 let rules_exists = rules_file.exists();
105
106 let hooks_file = self.hooks_path(scope)?;
107 let presence = json_patch::tagged_hook_presence(&hooks_file, &[], tag)?;
108
109 let hook_present = matches!(
110 presence,
111 ConfigPresence::Single | ConfigPresence::Duplicate { .. }
112 );
113
114 let status = if rules_exists || hook_present {
115 InstallStatus::InstalledOwned {
116 owner: tag.to_string(),
117 }
118 } else if let ConfigPresence::Invalid { reason } = &presence {
119 InstallStatus::Drifted {
120 issues: vec![crate::status::DriftIssue::InvalidConfig {
121 path: hooks_file.clone(),
122 reason: reason.clone(),
123 }],
124 }
125 } else {
126 InstallStatus::Absent
127 };
128
129 let mut files = vec![if rules_exists {
130 PathStatus::Exists {
131 path: rules_file.clone(),
132 }
133 } else {
134 PathStatus::Missing {
135 path: rules_file.clone(),
136 }
137 }];
138 files.push(if hooks_file.exists() {
139 PathStatus::Exists {
140 path: hooks_file.clone(),
141 }
142 } else {
143 PathStatus::Missing {
144 path: hooks_file.clone(),
145 }
146 });
147
148 Ok(StatusReport {
149 target: PlanTarget::Hook {
150 tag: tag.to_string(),
151 },
152 status,
153 config_path: Some(hooks_file),
154 ledger_path: None,
155 files,
156 warnings: Vec::new(),
157 })
158 }
159
160 fn plan_install(
161 &self,
162 scope: &Scope,
163 spec: &HookSpec,
164 ) -> Result<InstallPlan, AgentConfigError> {
165 HookSpec::validate_tag(&spec.tag)?;
166 let target = DryPlanTarget::Hook {
167 integration_id: Integration::id(self),
168 scope: scope.clone(),
169 tag: spec.tag.clone(),
170 };
171 let root = match self.project_root(scope) {
172 Ok(root) => root,
173 Err(AgentConfigError::UnsupportedScope { .. }) => {
174 return Ok(InstallPlan::refused(
175 target,
176 None,
177 RefusalReason::UnsupportedScope,
178 ));
179 }
180 Err(e) => return Err(e),
181 };
182 let mut changes = Vec::new();
183 if let Some(rules) = &spec.rules {
184 changes.extend(rules_dir::plan_install(
185 root,
186 RULES_DIR,
187 &spec.tag,
188 &rules.content,
189 )?);
190 }
191 if has_refusal(&changes) {
192 return Ok(InstallPlan::from_changes(target, changes));
193 }
194
195 if spec.script.is_some() || spec.rules.is_none() {
196 let event_key = event_to_windsurf(&spec.event);
197 let p = self.hooks_path(scope)?;
198 let entry = json!({
199 "bash": spec.command.render_shell(),
200 });
201 planning::plan_tagged_json_upsert(
202 &mut changes,
203 &p,
204 &[event_key.as_str()],
205 &spec.tag,
206 entry,
207 |_| {},
208 )?;
209 }
210
211 Ok(InstallPlan::from_changes(target, changes))
212 }
213
214 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
215 HookSpec::validate_tag(tag)?;
216 let target = DryPlanTarget::Hook {
217 integration_id: Integration::id(self),
218 scope: scope.clone(),
219 tag: tag.to_string(),
220 };
221 let root = match self.project_root(scope) {
222 Ok(root) => root,
223 Err(AgentConfigError::UnsupportedScope { .. }) => {
224 return Ok(UninstallPlan::refused(
225 target,
226 None,
227 RefusalReason::UnsupportedScope,
228 ));
229 }
230 Err(e) => return Err(e),
231 };
232 let mut changes = rules_dir::plan_uninstall(root, RULES_DIR, tag)?;
233 let p = self.hooks_path(scope)?;
234 planning::plan_tagged_json_remove_under(
235 &mut changes,
236 &p,
237 &[],
238 tag,
239 planning::json_object_empty,
240 false,
241 )?;
242 Ok(UninstallPlan::from_changes(target, changes))
243 }
244
245 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
246 HookSpec::validate_tag(&spec.tag)?;
247 let root = self.project_root(scope)?;
248 let mut report = InstallReport::default();
249
250 if let Some(rules) = &spec.rules {
251 scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, &spec.tag))?;
252 let r = rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)?;
253 report.merge(r);
254 }
255
256 if spec.script.is_some() || spec.rules.is_none() {
259 let event_key = event_to_windsurf(&spec.event);
260 let p = self.hooks_path(scope)?;
261 scope.ensure_contained(&p)?;
262 file_lock::with_lock(&p, || {
263 let mut root_doc = json_patch::read_or_empty(&p)?;
264 let entry = json!({
265 "bash": spec.command.render_shell(),
266 });
267 let changed = json_patch::upsert_tagged_array_entry(
268 &mut root_doc,
269 &[event_key.as_str()],
270 &spec.tag,
271 entry,
272 )?;
273 if changed {
274 let bytes = json_patch::to_pretty(&root_doc);
275 let outcome = safe_fs::write(scope, &p, &bytes, true)?;
276 if outcome.existed {
277 report.patched.push(outcome.path.clone());
278 } else {
279 report.created.push(outcome.path.clone());
280 }
281 if let Some(b) = outcome.backup {
282 report.backed_up.push(b);
283 }
284 report.already_installed = false;
285 } else if report.created.is_empty() && report.patched.is_empty() {
286 report.already_installed = true;
287 }
288 Ok::<(), AgentConfigError>(())
289 })?;
290 }
291
292 Ok(report)
293 }
294
295 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
296 HookSpec::validate_tag(tag)?;
297 let root = self.project_root(scope)?;
298 let mut report = UninstallReport::default();
299
300 scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, tag))?;
301 let r = rules_dir::uninstall(scope, RULES_DIR, tag)?;
302 report.merge(r);
303
304 let p = self.hooks_path(scope)?;
305 scope.ensure_contained(&p)?;
306 if p.exists() {
307 file_lock::with_lock(&p, || {
308 let mut doc = json_patch::read_or_empty(&p)?;
309 let changed = json_patch::remove_tagged_array_entries_under(&mut doc, &[], tag)?;
310 if changed {
311 let now_empty = doc.as_object().map(|o| o.is_empty()).unwrap_or(true);
312 if now_empty {
313 safe_fs::remove_file(scope, &p)?;
317 safe_fs::remove_backup_if_exists(scope, &p)?;
318 report.removed.push(p.clone());
319 } else {
320 let bytes = json_patch::to_pretty(&doc);
321 safe_fs::write(scope, &p, &bytes, false)?;
322 report.patched.push(p.clone());
323 }
324 }
325 Ok::<(), AgentConfigError>(())
326 })?;
327 }
328
329 if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
330 report.not_installed = true;
331 }
332 Ok(report)
333 }
334}
335
336impl McpSurface for WindsurfAgent {
337 fn id(&self) -> &'static str {
338 "windsurf"
339 }
340
341 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
342 &[ScopeKind::Global, ScopeKind::Local]
343 }
344
345 fn mcp_status(
346 &self,
347 scope: &Scope,
348 name: &str,
349 expected_owner: &str,
350 ) -> Result<StatusReport, AgentConfigError> {
351 McpSpec::validate_name(name)?;
352 let cfg = Self::mcp_path(scope)?;
353 let ledger = ownership::mcp_ledger_for(&cfg);
354 let presence = mcp_json_object::config_presence(&cfg, name)?;
355 let recorded = ownership::owner_of(&ledger, name)?;
356 Ok(StatusReport::for_mcp(
357 name,
358 cfg,
359 ledger,
360 presence,
361 expected_owner,
362 recorded,
363 ))
364 }
365
366 fn plan_install_mcp(
367 &self,
368 scope: &Scope,
369 spec: &McpSpec,
370 ) -> Result<InstallPlan, AgentConfigError> {
371 agent_planning::mcp_json_object_install(
372 McpSurface::id(self),
373 scope,
374 spec,
375 Self::mcp_path(scope),
376 )
377 }
378
379 fn plan_uninstall_mcp(
380 &self,
381 scope: &Scope,
382 name: &str,
383 owner_tag: &str,
384 ) -> Result<UninstallPlan, AgentConfigError> {
385 agent_planning::mcp_json_object_uninstall(
386 McpSurface::id(self),
387 scope,
388 name,
389 owner_tag,
390 Self::mcp_path(scope),
391 )
392 }
393
394 fn install_mcp(
395 &self,
396 scope: &Scope,
397 spec: &McpSpec,
398 ) -> Result<InstallReport, AgentConfigError> {
399 spec.validate()?;
400 let cfg = Self::mcp_path(scope)?;
401 spec.validate_local_secret_policy(scope)?;
402 scope.ensure_contained(&cfg)?;
403 let ledger = ownership::mcp_ledger_for(&cfg);
404 mcp_json_object::install(&cfg, &ledger, spec)
405 }
406
407 fn uninstall_mcp(
408 &self,
409 scope: &Scope,
410 name: &str,
411 owner_tag: &str,
412 ) -> Result<UninstallReport, AgentConfigError> {
413 McpSpec::validate_name(name)?;
414 HookSpec::validate_tag(owner_tag)?;
415 let cfg = Self::mcp_path(scope)?;
416 scope.ensure_contained(&cfg)?;
417 let ledger = ownership::mcp_ledger_for(&cfg);
418 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
419 }
420}
421
422impl SkillSurface for WindsurfAgent {
423 fn id(&self) -> &'static str {
424 "windsurf"
425 }
426
427 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
428 &[ScopeKind::Global, ScopeKind::Local]
429 }
430
431 fn skill_status(
432 &self,
433 scope: &Scope,
434 name: &str,
435 expected_owner: &str,
436 ) -> Result<StatusReport, AgentConfigError> {
437 SkillSpec::validate_name(name)?;
438 let root = Self::skills_root(scope)?;
439 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
440 let recorded = ownership::owner_of(&ledger, name)?;
441 Ok(StatusReport::for_skill(
442 name,
443 dir,
444 manifest,
445 ledger,
446 expected_owner,
447 recorded,
448 ))
449 }
450
451 fn plan_install_skill(
452 &self,
453 scope: &Scope,
454 spec: &SkillSpec,
455 ) -> Result<InstallPlan, AgentConfigError> {
456 agent_planning::skill_install(
457 SkillSurface::id(self),
458 scope,
459 spec,
460 Self::skills_root(scope),
461 )
462 }
463
464 fn plan_uninstall_skill(
465 &self,
466 scope: &Scope,
467 name: &str,
468 owner_tag: &str,
469 ) -> Result<UninstallPlan, AgentConfigError> {
470 agent_planning::skill_uninstall(
471 SkillSurface::id(self),
472 scope,
473 name,
474 owner_tag,
475 Self::skills_root(scope),
476 )
477 }
478
479 fn install_skill(
480 &self,
481 scope: &Scope,
482 spec: &SkillSpec,
483 ) -> Result<InstallReport, AgentConfigError> {
484 let root = Self::skills_root(scope)?;
485 scope.ensure_contained(&root)?;
486 skills_dir::install(&root, spec)
487 }
488
489 fn uninstall_skill(
490 &self,
491 scope: &Scope,
492 name: &str,
493 owner_tag: &str,
494 ) -> Result<UninstallReport, AgentConfigError> {
495 let root = Self::skills_root(scope)?;
496 scope.ensure_contained(&root)?;
497 skills_dir::uninstall(&root, name, owner_tag)
498 }
499}
500
501impl WindsurfAgent {
502 fn standalone_layout(
503 &self,
504 scope: &Scope,
505 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
506 let root = self.project_root(scope)?;
507 Ok(instructions_dir::StandaloneLayout {
508 config_dir: root.join(".windsurf"),
509 instruction_dir: root.join(".windsurf/rules"),
510 })
511 }
512}
513
514impl InstructionSurface for WindsurfAgent {
515 fn id(&self) -> &'static str {
516 "windsurf"
517 }
518
519 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
520 &[ScopeKind::Local]
521 }
522
523 fn instruction_status(
524 &self,
525 scope: &Scope,
526 name: &str,
527 expected_owner: &str,
528 ) -> Result<StatusReport, AgentConfigError> {
529 instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
530 }
531
532 fn plan_install_instruction(
533 &self,
534 scope: &Scope,
535 spec: &InstructionSpec,
536 ) -> Result<InstallPlan, AgentConfigError> {
537 instructions_dir::standalone_plan_install(
538 InstructionSurface::id(self),
539 scope,
540 self.standalone_layout(scope),
541 spec,
542 )
543 }
544
545 fn plan_uninstall_instruction(
546 &self,
547 scope: &Scope,
548 name: &str,
549 owner_tag: &str,
550 ) -> Result<UninstallPlan, AgentConfigError> {
551 instructions_dir::standalone_plan_uninstall(
552 InstructionSurface::id(self),
553 scope,
554 self.standalone_layout(scope),
555 name,
556 owner_tag,
557 )
558 }
559
560 fn install_instruction(
561 &self,
562 scope: &Scope,
563 spec: &InstructionSpec,
564 ) -> Result<InstallReport, AgentConfigError> {
565 instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
566 }
567
568 fn uninstall_instruction(
569 &self,
570 scope: &Scope,
571 name: &str,
572 owner_tag: &str,
573 ) -> Result<UninstallReport, AgentConfigError> {
574 instructions_dir::standalone_uninstall(
575 scope,
576 self.standalone_layout(scope)?,
577 name,
578 owner_tag,
579 )
580 }
581}
582
583fn event_to_windsurf(event: &Event) -> String {
588 match event {
589 Event::PreToolUse => "pre_run_command".into(),
590 Event::PostToolUse => "post_cascade_response".into(),
591 Event::Custom(s) => s.clone(),
592 }
593}
594
595#[cfg(test)]
598fn known_event_keys() -> &'static [&'static str] {
599 &[
600 "pre_user_prompt",
601 "pre_read_code",
602 "pre_write_code",
603 "pre_run_command",
604 "pre_mcp_tool_use",
605 "post_cascade_response",
606 "post_user_prompt",
607 "post_read_code",
608 "post_write_code",
609 "post_run_command",
610 "post_mcp_tool_use",
611 ]
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::spec::InstructionPlacement;
618 use serde_json::Value;
619 use std::fs;
620 use tempfile::tempdir;
621
622 fn read_json(p: &std::path::Path) -> Value {
623 serde_json::from_slice(&fs::read(p).unwrap()).unwrap()
624 }
625
626 fn rules_spec(tag: &str, body: &str) -> HookSpec {
627 HookSpec::builder(tag)
628 .command_program("noop", [] as [&str; 0])
629 .rules(body)
630 .build()
631 }
632
633 fn hook_spec(tag: &str, event: Event, command: &str) -> HookSpec {
634 HookSpec::builder(tag)
635 .command_shell_unchecked(command)
636 .event(event)
637 .build()
638 }
639
640 #[test]
641 fn install_rules_writes_dot_windsurf_rules_file() {
642 let dir = tempdir().unwrap();
643 let agent = WindsurfAgent::new();
644 let scope = Scope::Local(dir.path().to_path_buf());
645 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
646 assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
647 }
648
649 #[test]
650 fn install_default_event_writes_pre_run_command_entry() {
651 let dir = tempdir().unwrap();
652 let agent = WindsurfAgent::new();
653 let scope = Scope::Local(dir.path().to_path_buf());
654 agent
655 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "myapp hook"))
656 .unwrap();
657 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
658 let arr = v["pre_run_command"].as_array().unwrap();
659 assert_eq!(arr.len(), 1);
660 assert_eq!(arr[0]["bash"], serde_json::json!("myapp hook"));
661 assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("alpha"));
662 }
663
664 #[test]
665 fn install_custom_event_passes_through() {
666 let dir = tempdir().unwrap();
667 let agent = WindsurfAgent::new();
668 let scope = Scope::Local(dir.path().to_path_buf());
669 agent
670 .install(
671 &scope,
672 &hook_spec("alpha", Event::Custom("pre_write_code".into()), "x"),
673 )
674 .unwrap();
675 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
676 assert!(v["pre_write_code"].is_array());
677 }
678
679 #[test]
680 fn install_idempotent() {
681 let dir = tempdir().unwrap();
682 let agent = WindsurfAgent::new();
683 let scope = Scope::Local(dir.path().to_path_buf());
684 let s = hook_spec("alpha", Event::PreToolUse, "x");
685 agent.install(&scope, &s).unwrap();
686 let r2 = agent.install(&scope, &s).unwrap();
687 assert!(r2.already_installed);
688 }
689
690 #[test]
691 fn install_coexists_with_other_consumer() {
692 let dir = tempdir().unwrap();
693 let agent = WindsurfAgent::new();
694 let scope = Scope::Local(dir.path().to_path_buf());
695 agent
696 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
697 .unwrap();
698 agent
699 .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
700 .unwrap();
701 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
702 let arr = v["pre_run_command"].as_array().unwrap();
703 assert_eq!(arr.len(), 2);
704 }
705
706 #[test]
707 fn install_mcp_writes_mcp_config_separate_from_hooks() {
708 let dir = tempdir().unwrap();
709 let agent = WindsurfAgent::new();
710 let scope = Scope::Local(dir.path().to_path_buf());
711 let spec = McpSpec::builder("github")
712 .owner("myapp")
713 .stdio("npx", ["@example/server"])
714 .build();
715 agent.install_mcp(&scope, &spec).unwrap();
716 agent
717 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "x"))
718 .unwrap();
719 assert!(dir.path().join(".windsurf/mcp_config.json").exists());
720 assert!(dir.path().join(".windsurf/hooks.json").exists());
721 }
722
723 #[test]
724 fn mcp_supports_global_and_local_scopes() {
725 let agent = WindsurfAgent::new();
726 let scopes = agent.supported_mcp_scopes();
727 assert!(scopes.contains(&ScopeKind::Global));
728 assert!(scopes.contains(&ScopeKind::Local));
729 }
730
731 #[test]
732 fn uninstall_mcp_owner_mismatch_refused() {
733 let dir = tempdir().unwrap();
734 let agent = WindsurfAgent::new();
735 let scope = Scope::Local(dir.path().to_path_buf());
736 let spec = McpSpec::builder("github")
737 .owner("appA")
738 .stdio("npx", ["@example/server"])
739 .build();
740 agent.install_mcp(&scope, &spec).unwrap();
741 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
742 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
743 }
744
745 #[test]
746 fn uninstall_strips_tagged_entry_from_all_event_arrays() {
747 let dir = tempdir().unwrap();
748 let agent = WindsurfAgent::new();
749 let scope = Scope::Local(dir.path().to_path_buf());
750 agent
751 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "a"))
752 .unwrap();
753 agent
754 .install(&scope, &hook_spec("alpha", Event::PostToolUse, "b"))
755 .unwrap();
756 agent.uninstall(&scope, "alpha").unwrap();
757 let p = dir.path().join(".windsurf/hooks.json");
759 assert!(!agent.is_installed(&scope, "alpha").unwrap());
760 if p.exists() {
761 let v = read_json(&p);
762 for key in known_event_keys() {
763 let Some(arr) = v.get(key).and_then(|x| x.as_array()) else {
764 continue;
765 };
766 assert!(
767 !arr.iter().any(|e| e["_agent_config_tag"] == "alpha"),
768 "tag should be stripped from {key}"
769 );
770 }
771 }
772 }
773
774 #[test]
775 fn uninstall_keeps_other_consumer_entries() {
776 let dir = tempdir().unwrap();
777 let agent = WindsurfAgent::new();
778 let scope = Scope::Local(dir.path().to_path_buf());
779 agent
780 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
781 .unwrap();
782 agent
783 .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
784 .unwrap();
785 agent.uninstall(&scope, "appA").unwrap();
786 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
787 let arr = v["pre_run_command"].as_array().unwrap();
788 assert_eq!(arr.len(), 1);
789 assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("appB"));
790 }
791
792 #[test]
793 fn rejects_global_scope() {
794 let agent = WindsurfAgent::new();
795 let err = agent.is_installed(&Scope::Global, "x").unwrap_err();
796 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
797 }
798
799 #[test]
800 fn install_rules_and_hook_independent() {
801 let dir = tempdir().unwrap();
802 let agent = WindsurfAgent::new();
803 let scope = Scope::Local(dir.path().to_path_buf());
804 agent
805 .install(&scope, &rules_spec("alpha", "rules body"))
806 .unwrap();
807 assert!(!dir.path().join(".windsurf/hooks.json").exists());
809 assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
810 }
811
812 fn instruction_spec(name: &str, owner: &str, body: &str) -> InstructionSpec {
813 InstructionSpec::builder(name)
814 .owner(owner)
815 .placement(InstructionPlacement::StandaloneFile)
816 .body(body)
817 .build()
818 }
819
820 #[test]
821 fn instruction_writes_to_rules_dir() {
822 let dir = tempdir().unwrap();
823 let agent = WindsurfAgent::new();
824 let scope = Scope::Local(dir.path().to_path_buf());
825 agent
826 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
827 .unwrap();
828 let instr = dir.path().join(".windsurf/rules/MYAPP.md");
829 assert!(instr.exists());
830 assert!(fs::read_to_string(&instr).unwrap().contains("# Use MyApp"));
831 }
832
833 #[test]
834 fn instruction_uninstall_removes_file() {
835 let dir = tempdir().unwrap();
836 let agent = WindsurfAgent::new();
837 let scope = Scope::Local(dir.path().to_path_buf());
838 agent
839 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
840 .unwrap();
841 agent
842 .uninstall_instruction(&scope, "MYAPP", "myapp")
843 .unwrap();
844 assert!(!dir.path().join(".windsurf/rules/MYAPP.md").exists());
845 }
846
847 #[test]
848 fn instruction_rejects_global_scope() {
849 let agent = WindsurfAgent::new();
850 let err = agent
851 .install_instruction(
852 &Scope::Global,
853 &instruction_spec("MYAPP", "myapp", "body\n"),
854 )
855 .unwrap_err();
856 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
857 }
858}