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 other => other.as_str().into(),
593 }
594}
595
596#[cfg(test)]
599fn known_event_keys() -> &'static [&'static str] {
600 &[
601 "pre_user_prompt",
602 "pre_read_code",
603 "pre_write_code",
604 "pre_run_command",
605 "pre_mcp_tool_use",
606 "post_cascade_response",
607 "post_user_prompt",
608 "post_read_code",
609 "post_write_code",
610 "post_run_command",
611 "post_mcp_tool_use",
612 ]
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::spec::InstructionPlacement;
619 use serde_json::Value;
620 use std::fs;
621 use tempfile::tempdir;
622
623 fn read_json(p: &std::path::Path) -> Value {
624 serde_json::from_slice(&fs::read(p).unwrap()).unwrap()
625 }
626
627 fn rules_spec(tag: &str, body: &str) -> HookSpec {
628 HookSpec::builder(tag)
629 .command_program("noop", [] as [&str; 0])
630 .rules(body)
631 .build()
632 }
633
634 fn hook_spec(tag: &str, event: Event, command: &str) -> HookSpec {
635 HookSpec::builder(tag)
636 .command_shell_unchecked(command)
637 .event(event)
638 .build()
639 }
640
641 #[test]
642 fn install_rules_writes_dot_windsurf_rules_file() {
643 let dir = tempdir().unwrap();
644 let agent = WindsurfAgent::new();
645 let scope = Scope::Local(dir.path().to_path_buf());
646 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
647 assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
648 }
649
650 #[test]
651 fn install_default_event_writes_pre_run_command_entry() {
652 let dir = tempdir().unwrap();
653 let agent = WindsurfAgent::new();
654 let scope = Scope::Local(dir.path().to_path_buf());
655 agent
656 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "myapp hook"))
657 .unwrap();
658 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
659 let arr = v["pre_run_command"].as_array().unwrap();
660 assert_eq!(arr.len(), 1);
661 assert_eq!(arr[0]["bash"], serde_json::json!("myapp hook"));
662 assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("alpha"));
663 }
664
665 #[test]
666 fn install_custom_event_passes_through() {
667 let dir = tempdir().unwrap();
668 let agent = WindsurfAgent::new();
669 let scope = Scope::Local(dir.path().to_path_buf());
670 agent
671 .install(
672 &scope,
673 &hook_spec("alpha", Event::Custom("pre_write_code".into()), "x"),
674 )
675 .unwrap();
676 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
677 assert!(v["pre_write_code"].is_array());
678 }
679
680 #[test]
681 fn install_idempotent() {
682 let dir = tempdir().unwrap();
683 let agent = WindsurfAgent::new();
684 let scope = Scope::Local(dir.path().to_path_buf());
685 let s = hook_spec("alpha", Event::PreToolUse, "x");
686 agent.install(&scope, &s).unwrap();
687 let r2 = agent.install(&scope, &s).unwrap();
688 assert!(r2.already_installed);
689 }
690
691 #[test]
692 fn install_coexists_with_other_consumer() {
693 let dir = tempdir().unwrap();
694 let agent = WindsurfAgent::new();
695 let scope = Scope::Local(dir.path().to_path_buf());
696 agent
697 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
698 .unwrap();
699 agent
700 .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
701 .unwrap();
702 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
703 let arr = v["pre_run_command"].as_array().unwrap();
704 assert_eq!(arr.len(), 2);
705 }
706
707 #[test]
708 fn install_mcp_writes_mcp_config_separate_from_hooks() {
709 let dir = tempdir().unwrap();
710 let agent = WindsurfAgent::new();
711 let scope = Scope::Local(dir.path().to_path_buf());
712 let spec = McpSpec::builder("github")
713 .owner("myapp")
714 .stdio("npx", ["@example/server"])
715 .build();
716 agent.install_mcp(&scope, &spec).unwrap();
717 agent
718 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "x"))
719 .unwrap();
720 assert!(dir.path().join(".windsurf/mcp_config.json").exists());
721 assert!(dir.path().join(".windsurf/hooks.json").exists());
722 }
723
724 #[test]
725 fn mcp_supports_global_and_local_scopes() {
726 let agent = WindsurfAgent::new();
727 let scopes = agent.supported_mcp_scopes();
728 assert!(scopes.contains(&ScopeKind::Global));
729 assert!(scopes.contains(&ScopeKind::Local));
730 }
731
732 #[test]
733 fn uninstall_mcp_owner_mismatch_refused() {
734 let dir = tempdir().unwrap();
735 let agent = WindsurfAgent::new();
736 let scope = Scope::Local(dir.path().to_path_buf());
737 let spec = McpSpec::builder("github")
738 .owner("appA")
739 .stdio("npx", ["@example/server"])
740 .build();
741 agent.install_mcp(&scope, &spec).unwrap();
742 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
743 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
744 }
745
746 #[test]
747 fn uninstall_strips_tagged_entry_from_all_event_arrays() {
748 let dir = tempdir().unwrap();
749 let agent = WindsurfAgent::new();
750 let scope = Scope::Local(dir.path().to_path_buf());
751 agent
752 .install(&scope, &hook_spec("alpha", Event::PreToolUse, "a"))
753 .unwrap();
754 agent
755 .install(&scope, &hook_spec("alpha", Event::PostToolUse, "b"))
756 .unwrap();
757 agent.uninstall(&scope, "alpha").unwrap();
758 let p = dir.path().join(".windsurf/hooks.json");
760 assert!(!agent.is_installed(&scope, "alpha").unwrap());
761 if p.exists() {
762 let v = read_json(&p);
763 for key in known_event_keys() {
764 let Some(arr) = v.get(key).and_then(|x| x.as_array()) else {
765 continue;
766 };
767 assert!(
768 !arr.iter().any(|e| e["_agent_config_tag"] == "alpha"),
769 "tag should be stripped from {key}"
770 );
771 }
772 }
773 }
774
775 #[test]
776 fn uninstall_keeps_other_consumer_entries() {
777 let dir = tempdir().unwrap();
778 let agent = WindsurfAgent::new();
779 let scope = Scope::Local(dir.path().to_path_buf());
780 agent
781 .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
782 .unwrap();
783 agent
784 .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
785 .unwrap();
786 agent.uninstall(&scope, "appA").unwrap();
787 let v = read_json(&dir.path().join(".windsurf/hooks.json"));
788 let arr = v["pre_run_command"].as_array().unwrap();
789 assert_eq!(arr.len(), 1);
790 assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("appB"));
791 }
792
793 #[test]
794 fn rejects_global_scope() {
795 let agent = WindsurfAgent::new();
796 let err = agent.is_installed(&Scope::Global, "x").unwrap_err();
797 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
798 }
799
800 #[test]
801 fn install_rules_and_hook_independent() {
802 let dir = tempdir().unwrap();
803 let agent = WindsurfAgent::new();
804 let scope = Scope::Local(dir.path().to_path_buf());
805 agent
806 .install(&scope, &rules_spec("alpha", "rules body"))
807 .unwrap();
808 assert!(!dir.path().join(".windsurf/hooks.json").exists());
810 assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
811 }
812
813 fn instruction_spec(name: &str, owner: &str, body: &str) -> InstructionSpec {
814 InstructionSpec::builder(name)
815 .owner(owner)
816 .placement(InstructionPlacement::StandaloneFile)
817 .body(body)
818 .build()
819 }
820
821 #[test]
822 fn instruction_writes_to_rules_dir() {
823 let dir = tempdir().unwrap();
824 let agent = WindsurfAgent::new();
825 let scope = Scope::Local(dir.path().to_path_buf());
826 agent
827 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
828 .unwrap();
829 let instr = dir.path().join(".windsurf/rules/MYAPP.md");
830 assert!(instr.exists());
831 assert!(fs::read_to_string(&instr).unwrap().contains("# Use MyApp"));
832 }
833
834 #[test]
835 fn instruction_uninstall_removes_file() {
836 let dir = tempdir().unwrap();
837 let agent = WindsurfAgent::new();
838 let scope = Scope::Local(dir.path().to_path_buf());
839 agent
840 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
841 .unwrap();
842 agent
843 .uninstall_instruction(&scope, "MYAPP", "myapp")
844 .unwrap();
845 assert!(!dir.path().join(".windsurf/rules/MYAPP.md").exists());
846 }
847
848 #[test]
849 fn instruction_rejects_global_scope() {
850 let agent = WindsurfAgent::new();
851 let err = agent
852 .install_instruction(
853 &Scope::Global,
854 &instruction_spec("MYAPP", "myapp", "body\n"),
855 )
856 .unwrap_err();
857 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
858 }
859}