1use anyhow::{Context, Result};
6use clap::ValueEnum;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::config::Registry;
11use crate::output;
12
13pub const EMBEDDED_SKILL_MD: &str = include_str!("../../.agents/skills/dev-prune/SKILL.md");
14
15pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
18
19#[derive(clap::ValueEnum, Clone, Copy, Debug)]
29pub enum AgentEditor {
30 Cursor,
32 Windsurf,
34 Antigravity,
36 Cline,
38 Roo,
40 Kilocode,
42 Continue,
44 AmazonQ,
46 Kiro,
48 Trae,
50 Junie,
52 Gemini,
54 Zed,
56 Copilot,
58 Aider,
60 AgentsMd,
63}
64
65enum Style {
67 OwnFile,
69 CursorMdc,
71 MarkedBlock,
74}
75
76impl AgentEditor {
77 fn target(self) -> (&'static str, Style) {
84 use crate::constants as c;
85 match self {
86 AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
87 AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
88 AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
89 AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
90 AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
91 AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
92 AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
93 AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
94 AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
95 AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
96 AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
97 AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
98 AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
99 AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
100 AgentEditor::Aider => (c::AIDER_CONVENTIONS_FILE, Style::MarkedBlock),
101 AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
102 }
103 }
104
105 fn wiring(self) -> Option<&'static str> {
109 match self {
110 AgentEditor::Aider => Some(
111 "Aider does not read this file on its own. Add `read: CONVENTIONS.md` \
112 to `.aider.conf.yml`, or start it with `aider --read CONVENTIONS.md`.",
113 ),
114 _ => None,
115 }
116 }
117
118 fn label(self) -> &'static str {
120 match self {
121 AgentEditor::Cursor => "Cursor",
122 AgentEditor::Windsurf => "Windsurf",
123 AgentEditor::Antigravity => "Antigravity",
124 AgentEditor::Cline => "Cline",
125 AgentEditor::Roo => "Roo Code",
126 AgentEditor::Kilocode => "Kilo Code",
127 AgentEditor::Continue => "Continue",
128 AgentEditor::AmazonQ => "Amazon Q Developer",
129 AgentEditor::Kiro => "Kiro",
130 AgentEditor::Trae => "Trae",
131 AgentEditor::Junie => "JetBrains Junie",
132 AgentEditor::Gemini => "Gemini CLI",
133 AgentEditor::Zed => "Zed",
134 AgentEditor::Copilot => "GitHub Copilot",
135 AgentEditor::Aider => "Aider",
136 AgentEditor::AgentsMd => "AGENTS.md readers (Codex, Jules, Amp, OpenCode)",
137 }
138 }
139
140 fn traces(self) -> Traces {
153 const NONE: Traces = Traces {
154 home: &[],
155 repo: &[],
156 extension: None,
157 };
158 match self {
159 AgentEditor::Cursor => Traces {
160 home: &[".cursor"],
161 repo: &[".cursor"],
162 ..NONE
163 },
164 AgentEditor::Windsurf => Traces {
165 home: &[".codeium/windsurf", ".windsurf"],
166 repo: &[".windsurf"],
167 ..NONE
168 },
169 AgentEditor::Antigravity => Traces {
174 home: &[".antigravity-ide", ".gemini/antigravity"],
175 repo: &[".agent"],
176 ..NONE
177 },
178 AgentEditor::Cline => Traces {
179 repo: &[".clinerules"],
180 extension: Some("saoudrizwan.claude-dev"),
181 ..NONE
182 },
183 AgentEditor::Roo => Traces {
186 repo: &[".roo"],
187 extension: Some("rooveterinaryinc.roo"),
188 ..NONE
189 },
190 AgentEditor::Kilocode => Traces {
191 repo: &[".kilocode"],
192 extension: Some("kilocode.kilo-code"),
193 ..NONE
194 },
195 AgentEditor::Continue => Traces {
196 home: &[".continue"],
197 repo: &[".continue"],
198 extension: Some("continue.continue"),
199 },
200 AgentEditor::AmazonQ => Traces {
201 home: &[".aws/amazonq"],
202 repo: &[".amazonq"],
203 extension: Some("amazonwebservices.amazon-q-vscode"),
204 },
205 AgentEditor::Kiro => Traces {
206 home: &[".kiro"],
207 repo: &[".kiro"],
208 ..NONE
209 },
210 AgentEditor::Trae => Traces {
211 home: &[".trae"],
212 repo: &[".trae"],
213 ..NONE
214 },
215 AgentEditor::Junie => Traces {
216 repo: &[".junie"],
217 ..NONE
218 },
219 AgentEditor::Gemini => Traces {
220 home: &[".gemini/settings.json"],
221 repo: &["GEMINI.md"],
222 ..NONE
223 },
224 AgentEditor::Zed => Traces {
225 home: &[".config/zed", "AppData/Roaming/Zed"],
226 repo: &[".rules"],
227 ..NONE
228 },
229 AgentEditor::Copilot => Traces {
232 home: &[".copilot"],
233 repo: &[".github/copilot-instructions.md"],
234 extension: Some("github.copilot"),
235 },
236 AgentEditor::Aider => Traces {
237 home: &[".aider.conf.yml"],
238 repo: &[".aider.conf.yml"],
239 ..NONE
240 },
241 AgentEditor::AgentsMd => Traces {
242 home: &[".codex", ".config/opencode", ".opencode"],
243 repo: &["AGENTS.md"],
244 ..NONE
245 },
246 }
247 }
248
249 fn detected_under(self, home: &std::path::Path, repo: Option<&std::path::Path>) -> bool {
250 let traces = self.traces();
251 traces.home.iter().any(|t| home.join(t).exists())
252 || traces
253 .extension
254 .is_some_and(|prefix| extension_present(home, prefix))
255 || repo.is_some_and(|r| traces.repo.iter().any(|t| r.join(t).exists()))
256 }
257
258 fn rules_state(self, repo: &std::path::Path) -> RulesState {
262 let (relative, style) = self.target();
263 let Ok(existing) = fs::read_to_string(repo.join(relative)) else {
264 return RulesState::Missing;
265 };
266 match style {
267 Style::OwnFile | Style::CursorMdc => {
268 if existing.contains(EMBEDDED_RULES_MD) {
269 RulesState::Current
270 } else {
271 RulesState::Stale
272 }
273 }
274 Style::MarkedBlock => {
275 if !existing.contains(crate::constants::RULES_BLOCK_START) {
276 RulesState::Missing
277 } else if existing.contains(EMBEDDED_RULES_MD) {
278 RulesState::Current
279 } else {
280 RulesState::Stale
281 }
282 }
283 }
284 }
285
286 fn flag_value(self) -> String {
290 use clap::ValueEnum;
291 self.to_possible_value()
292 .expect("no skipped variants")
293 .get_name()
294 .to_string()
295 }
296}
297
298#[derive(clap::ValueEnum, Clone, Copy, Debug)]
301pub enum PromptKind {
302 Workspace,
304 Agent,
306}
307
308impl PromptKind {
309 fn flag_value(self) -> String {
312 self.to_possible_value()
313 .expect("PromptKind has no skipped variants")
314 .get_name()
315 .to_string()
316 }
317}
318
319struct Traces {
321 home: &'static [&'static str],
323 repo: &'static [&'static str],
326 extension: Option<&'static str>,
329}
330
331enum RulesState {
333 Current,
334 Stale,
335 Missing,
336}
337
338const EXTENSION_ROOTS: &[&str] = &[
342 ".vscode/extensions",
343 ".vscode-insiders/extensions",
344 ".vscode-oss/extensions",
345 ".antigravity-ide/extensions",
346 ".cursor/extensions",
347 ".windsurf/extensions",
348 ".trae/extensions",
349 ".kiro/extensions",
350];
351
352fn extension_present(home: &std::path::Path, prefix: &str) -> bool {
359 EXTENSION_ROOTS.iter().any(|root| {
360 fs::read_dir(home.join(root)).is_ok_and(|entries| {
361 entries.flatten().any(|entry| {
362 entry
363 .file_name()
364 .to_string_lossy()
365 .to_ascii_lowercase()
366 .starts_with(prefix)
367 })
368 })
369 })
370}
371
372pub fn detect_editors(home: &std::path::Path, repo: Option<&std::path::Path>) -> Vec<AgentEditor> {
376 use clap::ValueEnum;
377 AgentEditor::value_variants()
378 .iter()
379 .copied()
380 .filter(|editor| editor.detected_under(home, repo))
381 .collect()
382}
383
384fn workspace_prompt(skill_path: &str) -> String {
388 format!(
389 "Read the dev-prune AI skill at file://{skill_path}. Then run \
390 `devp init --auto --dry-run`, tell me what it found, and run `devp init --auto` \
391 once I confirm it, to register every Git repository in my workspace."
392 )
393}
394
395fn agent_prompt(skill_path: &str) -> String {
398 format!(
399 "I have installed `dev-prune` on my machine. Read the skill at file://{skill_path}. \
400 If you are Claude Code, it is already installed globally, from the export above. \
401 Any other coding agent should instead run `devp skill --agent <name>` in each \
402 repository — `devp skill --help` lists every `<name>`, `agents-md` covers anything \
403 that reads the shared `AGENTS.md` convention, and `devp skill --rules-file <path>` \
404 covers anything else, writing the same rules into whatever file you name."
405 )
406}
407
408pub fn run(
414 agent: Option<AgentEditor>,
415 rules_file: Option<PathBuf>,
416 prompt: Option<PromptKind>,
417 copy: bool,
418 detected: bool,
419) -> Result<()> {
420 if let Some(editor) = agent {
421 return write_agent_rules(editor);
422 }
423 if let Some(path) = rules_file {
424 return write_rules_to_path(&path);
425 }
426 if let Some(kind) = prompt {
427 let skill_path = output::clean_path(&skill_md_path()?);
428 let text = match kind {
429 PromptKind::Workspace => workspace_prompt(&skill_path),
430 PromptKind::Agent => agent_prompt(&skill_path),
431 };
432 if copy {
433 match copy_to_clipboard(&text) {
434 Ok(tool) => eprintln!("Copied to the clipboard, via {tool}."),
435 Err(()) => eprintln!(
436 "No clipboard tool found on PATH — pipe it yourself: `devp skill \
437 --prompt {} | <your clipboard command>`.",
438 kind.flag_value()
439 ),
440 }
441 }
442 println!("{text}");
443 return Ok(());
444 }
445 if detected {
446 return write_detected_rules();
447 }
448
449 output::print_header("dev-prune AI Agent Skill Integration");
450 let skill_path = export_skill_md()?;
451
452 output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
453
454 let agent_roots = crate::setup::agent_skill_roots();
457 match crate::setup::ensure_agent_skills() {
458 crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
459 for root in &agent_roots {
460 output::print_success(&format!(
461 "Skill installed for your AI agent at `{}`",
462 output::clean_path(root.join("SKILL.md"))
463 ));
464 }
465 }
466 crate::setup::Outcome::Skipped(_) => {
467 output::print_info(
468 "No AI agent skills directory was found — use the prompts below instead.",
469 );
470 }
471 crate::setup::Outcome::Failed(why) => {
472 output::print_warning(&format!(
473 "Could not install into the agent skills directory: {why}"
474 ));
475 }
476 }
477 print_detection_report();
478 println!();
479 output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
480 println!();
481 output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
482 println!("```markdown");
483 println!("{}", workspace_prompt(&skill_path));
484 println!("```");
485 println!();
486 output::print_info("Prompt 2: Onboard Any Other Coding Agent");
487 println!("```markdown");
488 println!("{}", agent_prompt(&skill_path));
489 println!("```");
490 println!();
491 output::print_info(
492 "`devp skill --prompt workspace` or `--prompt agent` prints either one on its \
493 own; add `--copy` to send it straight to the clipboard.",
494 );
495
496 Ok(())
497}
498
499fn skill_md_path() -> Result<PathBuf> {
502 Ok(Registry::config_dir()
503 .context("could not resolve the config directory")?
504 .join("SKILL.md"))
505}
506
507fn export_skill_md() -> Result<String> {
512 let target = skill_md_path()?;
513 if let Some(parent) = target.parent() {
514 fs::create_dir_all(parent)
515 .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
516 }
517 fs::write(&target, EMBEDDED_SKILL_MD)
518 .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
519 Ok(output::clean_path(&target))
520}
521
522fn require_repo(cwd: &std::path::Path, flag: &str) -> Result<()> {
525 if !crate::scanner::is_git_repo(cwd) {
526 anyhow::bail!(
527 "`{flag}` writes rules into a repository, and the current directory is not \
528 one. Run it from the repository root."
529 );
530 }
531 Ok(())
532}
533
534fn write_agent_rules(editor: AgentEditor) -> Result<()> {
540 let cwd = std::env::current_dir().context("could not read the current directory")?;
541 require_repo(&cwd, "--agent")?;
542 write_rules_for(editor, &cwd)?;
543 print_commit_note();
544 Ok(())
545}
546
547fn write_detected_rules() -> Result<()> {
551 let cwd = std::env::current_dir().context("could not read the current directory")?;
552 require_repo(&cwd, "--detected")?;
553 let home = dirs::home_dir().context("could not resolve the home directory")?;
554 let editors = detect_editors(&home, Some(&cwd));
555 if editors.is_empty() {
556 output::print_info(
557 "No editor left a trace on this machine or in this repository — nothing to \
558 write. `devp skill --agent <editor>` writes rules for one by name; `devp \
559 skill --help` lists them.",
560 );
561 return Ok(());
562 }
563 for editor in editors {
564 write_rules_for(editor, &cwd)?;
565 }
566 print_commit_note();
567 Ok(())
568}
569
570fn write_rules_for(editor: AgentEditor, cwd: &std::path::Path) -> Result<()> {
571 let (relative, style) = editor.target();
572 let target = cwd.join(relative);
573 let content = match style {
574 Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
575 Style::CursorMdc => format!(
576 "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
577 repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
578 ),
579 Style::MarkedBlock => {
580 let existing = fs::read_to_string(&target).unwrap_or_default();
581 upsert_marked_block(&existing)
582 }
583 };
584 write_rules_file(&target, &content)?;
585
586 output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
587 if let Some(wiring) = editor.wiring() {
588 output::print_info(wiring);
589 }
590 Ok(())
591}
592
593fn print_commit_note() {
594 output::print_info(
595 "Commit the file if the whole team's agents should have it; it is inert data \
596 and safe to share.",
597 );
598}
599
600fn print_detection_report() {
605 let Some(home) = dirs::home_dir() else {
608 return;
609 };
610 let repo = std::env::current_dir()
611 .ok()
612 .filter(|d| crate::scanner::is_git_repo(d));
613 let editors = detect_editors(&home, repo.as_deref());
614 if editors.is_empty() {
615 return;
616 }
617 println!();
618 output::print_header("Detected editors and agents");
619 let rows: Vec<(String, &'static str, String)> = editors
620 .iter()
621 .map(|editor| {
622 let state = match &repo {
623 Some(r) => match editor.rules_state(r) {
624 RulesState::Current => "rules current here",
625 RulesState::Stale => "rules stale here",
626 RulesState::Missing => "no rules here yet",
627 },
628 None => "",
629 };
630 (
631 editor.label().to_string(),
632 state,
633 format!("devp skill --agent {}", editor.flag_value()),
634 )
635 })
636 .collect();
637 let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
638 let state_width = rows.iter().map(|(_, s, _)| s.len()).max().unwrap_or(0);
639 for (label, state, command) in &rows {
640 println!(" {label:<label_width$} {state:<state_width$} {command}");
641 }
642 if repo.is_some() {
643 output::print_info(
644 "`devp skill --detected` writes rules for all of them into this repository \
645 in one pass.",
646 );
647 } else {
648 output::print_info(
649 "Run `devp skill --detected` from a repository root to write rules for all \
650 of them in one pass.",
651 );
652 }
653}
654
655fn write_rules_to_path(path: &Path) -> Result<()> {
664 if path.is_absolute()
665 || path
666 .components()
667 .any(|c| c == std::path::Component::ParentDir)
668 {
669 anyhow::bail!(
670 "`--rules-file` takes a path inside the repository — `{}` would write \
671 outside it.",
672 path.display()
673 );
674 }
675
676 let cwd = std::env::current_dir().context("could not read the current directory")?;
677 if !crate::scanner::is_git_repo(&cwd) {
678 anyhow::bail!(
679 "`--rules-file` writes rules into a repository, and the current directory \
680 is not one. Run it from the repository root."
681 );
682 }
683
684 let target = cwd.join(path);
685 let existing = fs::read_to_string(&target).unwrap_or_default();
686 let content = upsert_marked_block(&existing);
687 write_rules_file(&target, &content)?;
688
689 output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
690 output::print_info(
691 "Commit the file if the whole team's agents should have it; it is inert data \
692 and safe to share.",
693 );
694 Ok(())
695}
696
697fn upsert_marked_block(existing: &str) -> String {
700 let block = format!(
701 "{}\n{EMBEDDED_RULES_MD}{}\n",
702 crate::constants::RULES_BLOCK_START,
703 crate::constants::RULES_BLOCK_END
704 );
705 match (
706 existing.find(crate::constants::RULES_BLOCK_START),
707 existing.find(crate::constants::RULES_BLOCK_END),
708 ) {
709 (Some(start), Some(end)) if end > start => {
710 let after = end + crate::constants::RULES_BLOCK_END.len();
711 let after = if existing[after..].starts_with('\n') {
713 after + 1
714 } else {
715 after
716 };
717 format!("{}{block}{}", &existing[..start], &existing[after..])
718 }
719 _ if existing.is_empty() => block,
720 _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
721 }
722}
723
724fn write_rules_file(target: &Path, content: &str) -> Result<()> {
725 if let Some(parent) = target.parent() {
726 fs::create_dir_all(parent)
727 .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
728 }
729 fs::write(target, content)
730 .with_context(|| format!("could not write {}", output::clean_path(target)))
731}
732
733fn clipboard_candidates() -> Vec<(&'static str, &'static [&'static str])> {
735 use crate::constants as c;
736 match std::env::consts::OS {
737 "windows" => vec![(c::CLIPBOARD_COMMAND_WINDOWS, [].as_slice())],
738 "macos" => vec![(c::CLIPBOARD_COMMAND_MACOS, [].as_slice())],
739 _ => c::CLIPBOARD_COMMANDS_LINUX
741 .iter()
742 .copied()
743 .chain(std::iter::once((
744 c::CLIPBOARD_COMMAND_WINDOWS,
745 [].as_slice(),
746 )))
747 .collect(),
748 }
749}
750
751fn copy_to_clipboard(text: &str) -> Result<&'static str, ()> {
761 for (cmd, args) in clipboard_candidates() {
762 if try_clipboard_command(cmd, args, text) {
763 return Ok(cmd);
764 }
765 }
766 Err(())
767}
768
769fn try_clipboard_command(cmd: &str, args: &[&str], text: &str) -> bool {
770 use std::io::Write;
771 use std::process::{Command, Stdio};
772
773 let Ok(mut child) = Command::new(cmd)
774 .args(args)
775 .stdin(Stdio::piped())
776 .stdout(Stdio::null())
777 .stderr(Stdio::null())
778 .spawn()
779 else {
780 return false;
781 };
782 let Some(mut stdin) = child.stdin.take() else {
783 return false;
784 };
785 if stdin.write_all(text.as_bytes()).is_err() {
786 return false;
787 }
788 drop(stdin);
789 child.wait().map(|status| status.success()).unwrap_or(false)
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use clap::ValueEnum;
796
797 #[test]
798 fn every_editor_writes_to_its_own_file() {
799 let mut paths: Vec<&str> = AgentEditor::value_variants()
802 .iter()
803 .map(|e| e.target().0)
804 .collect();
805 let total = paths.len();
806 paths.sort_unstable();
807 paths.dedup();
808 assert_eq!(paths.len(), total, "two editors share a path");
809 }
810
811 #[test]
812 fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
813 for editor in AgentEditor::value_variants() {
818 if let Some(note) = editor.wiring() {
819 let path = editor.target().0;
820 assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
821 assert!(
822 note.contains(path),
823 "the note does not name the file: {note}"
824 );
825 }
826 }
827 assert!(
828 AgentEditor::Aider.wiring().is_some(),
829 "aider writes a file nothing reads until it is configured"
830 );
831 }
832
833 #[test]
834 fn a_shared_file_is_only_ever_edited_inside_the_markers() {
835 let theirs = "# Our conventions\n\nUse tabs.\n";
838 let once = upsert_marked_block(theirs);
839 let twice = upsert_marked_block(&once);
840 assert_eq!(once, twice, "a second write duplicated the block");
841 assert!(once.starts_with(theirs));
842 assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
843 }
844
845 #[test]
846 fn every_agent_value_is_named_in_the_ide_integration_doc() {
847 let doc = include_str!("../../docs/IDE_INTEGRATION.md");
851 for editor in AgentEditor::value_variants() {
852 let name = editor.to_possible_value().expect("no skipped variants");
853 let needle = format!("`{}`", name.get_name());
854 assert!(
855 doc.contains(&needle),
856 "docs/IDE_INTEGRATION.md's table does not mention `--agent {}`",
857 name.get_name()
858 );
859 }
860 }
861
862 #[test]
863 fn clipboard_has_a_command_to_try_on_every_platform() {
864 let candidates = clipboard_candidates();
865 assert!(!candidates.is_empty(), "no clipboard command to try");
866 assert!(candidates.iter().all(|(cmd, _)| !cmd.is_empty()));
867 }
868
869 #[test]
873 fn a_home_trace_is_enough() {
874 let home = tempfile::tempdir().unwrap();
875 std::fs::create_dir_all(home.path().join(".continue")).unwrap();
876 let found = detect_editors(home.path(), None);
877 assert!(
878 found.iter().any(|e| matches!(e, AgentEditor::Continue)),
879 "a `.continue` home directory did not detect Continue"
880 );
881 }
882
883 #[test]
884 fn a_bare_machine_detects_nothing() {
885 let home = tempfile::tempdir().unwrap();
886 assert!(detect_editors(home.path(), None).is_empty());
887 }
888
889 #[test]
890 fn an_extension_counts_only_by_publisher_prefix() {
891 let home = tempfile::tempdir().unwrap();
894 let ext = home.path().join(".vscode/extensions");
895 std::fs::create_dir_all(ext.join("nvidia.nsight-copilot-2026.1.21-win32-x64")).unwrap();
896 assert!(
897 !detect_editors(home.path(), None)
898 .iter()
899 .any(|e| matches!(e, AgentEditor::Copilot)),
900 "a third-party extension containing 'copilot' read as GitHub Copilot"
901 );
902
903 std::fs::create_dir_all(ext.join("github.copilot-1.350.0")).unwrap();
904 assert!(
905 detect_editors(home.path(), None)
906 .iter()
907 .any(|e| matches!(e, AgentEditor::Copilot)),
908 "the real github.copilot extension was not detected"
909 );
910 }
911
912 #[test]
913 fn repo_traces_apply_only_when_a_repository_is_given() {
914 let home = tempfile::tempdir().unwrap();
915 let repo = tempfile::tempdir().unwrap();
916 std::fs::write(repo.path().join("GEMINI.md"), "# rules\n").unwrap();
917 assert!(
918 detect_editors(home.path(), None).is_empty(),
919 "a repo trace was counted with no repository in play"
920 );
921 assert!(
922 detect_editors(home.path(), Some(repo.path()))
923 .iter()
924 .any(|e| matches!(e, AgentEditor::Gemini)),
925 "GEMINI.md in the repository did not detect the Gemini CLI"
926 );
927 }
928
929 #[test]
930 fn rules_state_tells_missing_from_stale_from_current() {
931 let repo = tempfile::tempdir().unwrap();
932 let editor = AgentEditor::Windsurf;
933 assert!(matches!(
934 editor.rules_state(repo.path()),
935 RulesState::Missing
936 ));
937
938 let target = repo.path().join(editor.target().0);
939 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
940 std::fs::write(&target, "an older release's rules\n").unwrap();
941 assert!(matches!(editor.rules_state(repo.path()), RulesState::Stale));
942
943 std::fs::write(&target, EMBEDDED_RULES_MD).unwrap();
944 assert!(matches!(
945 editor.rules_state(repo.path()),
946 RulesState::Current
947 ));
948 }
949
950 #[test]
951 fn a_shared_file_without_the_markers_counts_as_missing() {
952 let repo = tempfile::tempdir().unwrap();
955 std::fs::write(repo.path().join("AGENTS.md"), "# Their agents file\n").unwrap();
956 assert!(matches!(
957 AgentEditor::AgentsMd.rules_state(repo.path()),
958 RulesState::Missing
959 ));
960 }
961}