Skip to main content

dev_prune/commands/
skill.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4/// AI Agent Skill exporter & onboarding prompt generator.
5use anyhow::{Context, Result};
6use std::fs;
7
8use crate::config::Registry;
9use crate::output;
10
11pub const EMBEDDED_SKILL_MD: &str = include_str!("../../.agents/skills/dev-prune/SKILL.md");
12
13/// The condensed rules `--agent` writes: what the tool is, the non-negotiables, and a
14/// pointer at the full SKILL.md — short enough that an editor loads it on every turn.
15pub const EMBEDDED_RULES_MD: &str = include_str!("../../.agents/rules/dev-prune.rules.md");
16
17/// Editors whose agents read per-repository rule files.
18///
19/// Claude Code is deliberately absent: its skill installs globally (`devp skill`,
20/// `devp setup`), so there is nothing to write into individual repositories.
21#[derive(clap::ValueEnum, Clone, Copy, Debug)]
22pub enum AgentEditor {
23    /// `.cursor/rules/dev-prune.mdc`
24    Cursor,
25    /// `.windsurf/rules/dev-prune.md`
26    Windsurf,
27    /// `.agent/rules/dev-prune.md` (Antigravity)
28    Antigravity,
29    /// `.clinerules/dev-prune.md`
30    Cline,
31    /// `.roo/rules/dev-prune.md` (Roo Code)
32    Roo,
33    /// `.kilocode/rules/dev-prune.md` (Kilo Code)
34    Kilocode,
35    /// `.continue/rules/dev-prune.md` (Continue)
36    Continue,
37    /// `.amazonq/rules/dev-prune.md` (Amazon Q Developer)
38    AmazonQ,
39    /// `.kiro/steering/dev-prune.md` (Kiro)
40    Kiro,
41    /// `.trae/rules/dev-prune.md` (Trae)
42    Trae,
43    /// `.junie/guidelines.md`, as a marked block (JetBrains Junie)
44    Junie,
45    /// `GEMINI.md`, as a marked block (Gemini CLI)
46    Gemini,
47    /// `.rules`, as a marked block (Zed — read ahead of every other convention)
48    Zed,
49    /// `.github/copilot-instructions.md`, as a marked block
50    Copilot,
51    /// `CONVENTIONS.md`, as a marked block (Aider — which has to be told to read it)
52    Aider,
53    /// `AGENTS.md`, as a marked block — the cross-tool convention (Codex, Jules,
54    /// Amp, OpenCode, Antigravity and others read it)
55    AgentsMd,
56}
57
58/// How the rules go into the file.
59enum Style {
60    /// dev-prune owns the whole file.
61    OwnFile,
62    /// Cursor's `.mdc` format, which needs frontmatter above the rules.
63    CursorMdc,
64    /// The file belongs to somebody else, so dev-prune owns a marked block inside it
65    /// and leaves every byte outside the markers exactly as found.
66    MarkedBlock,
67}
68
69impl AgentEditor {
70    /// The repository-relative file this editor's agent actually reads, and how to
71    /// write into it.
72    ///
73    /// One table rather than one match arm each: an editor whose agent reads a
74    /// directory of rule files is the same three lines every time, and the only thing
75    /// a contributor should have to establish is the path.
76    fn target(self) -> (&'static str, Style) {
77        use crate::constants as c;
78        match self {
79            AgentEditor::Cursor => (c::CURSOR_RULES_FILE, Style::CursorMdc),
80            AgentEditor::Windsurf => (c::WINDSURF_RULES_FILE, Style::OwnFile),
81            AgentEditor::Antigravity => (c::ANTIGRAVITY_RULES_FILE, Style::OwnFile),
82            AgentEditor::Cline => (c::CLINE_RULES_FILE, Style::OwnFile),
83            AgentEditor::Roo => (c::ROO_RULES_FILE, Style::OwnFile),
84            AgentEditor::Kilocode => (c::KILOCODE_RULES_FILE, Style::OwnFile),
85            AgentEditor::Continue => (c::CONTINUE_RULES_FILE, Style::OwnFile),
86            AgentEditor::AmazonQ => (c::AMAZON_Q_RULES_FILE, Style::OwnFile),
87            AgentEditor::Kiro => (c::KIRO_STEERING_FILE, Style::OwnFile),
88            AgentEditor::Trae => (c::TRAE_RULES_FILE, Style::OwnFile),
89            AgentEditor::Junie => (c::JUNIE_GUIDELINES_FILE, Style::MarkedBlock),
90            AgentEditor::Gemini => (c::GEMINI_MD_FILE, Style::MarkedBlock),
91            AgentEditor::Zed => (c::ZED_RULES_FILE, Style::MarkedBlock),
92            AgentEditor::Copilot => (c::COPILOT_INSTRUCTIONS_FILE, Style::MarkedBlock),
93            AgentEditor::Aider => (c::AIDER_CONVENTIONS_FILE, Style::MarkedBlock),
94            AgentEditor::AgentsMd => (c::AGENTS_MD_FILE, Style::MarkedBlock),
95        }
96    }
97
98    /// What the user still has to do, for the one editor that does not read its
99    /// file unprompted. Aider loads `CONVENTIONS.md` only when told to, so writing
100    /// the file and saying nothing would leave rules an agent never sees.
101    fn wiring(self) -> Option<&'static str> {
102        match self {
103            AgentEditor::Aider => Some(
104                "Aider does not read this file on its own. Add `read: CONVENTIONS.md` \
105                 to `.aider.conf.yml`, or start it with `aider --read CONVENTIONS.md`.",
106            ),
107            _ => None,
108        }
109    }
110
111    /// The editor's name as a person knows it, for the detection report.
112    fn label(self) -> &'static str {
113        match self {
114            AgentEditor::Cursor => "Cursor",
115            AgentEditor::Windsurf => "Windsurf",
116            AgentEditor::Antigravity => "Antigravity",
117            AgentEditor::Cline => "Cline",
118            AgentEditor::Roo => "Roo Code",
119            AgentEditor::Kilocode => "Kilo Code",
120            AgentEditor::Continue => "Continue",
121            AgentEditor::AmazonQ => "Amazon Q Developer",
122            AgentEditor::Kiro => "Kiro",
123            AgentEditor::Trae => "Trae",
124            AgentEditor::Junie => "JetBrains Junie",
125            AgentEditor::Gemini => "Gemini CLI",
126            AgentEditor::Zed => "Zed",
127            AgentEditor::Copilot => "GitHub Copilot",
128            AgentEditor::Aider => "Aider",
129            AgentEditor::AgentsMd => "AGENTS.md readers (Codex, Jules, Amp, OpenCode)",
130        }
131    }
132
133    /// The on-disk traces that mark this editor as present, for the detection
134    /// report and `--detected`.
135    ///
136    /// Presence on disk, never process probing: every editor here leaves a
137    /// well-known directory or file behind once it has run, so an `exists()`
138    /// check finds it without spawning anything — which is also what lets the
139    /// tests fabricate a whole machine out of a temp directory. The strings live
140    /// inline rather than in `constants.rs` for the same reason as
141    /// `detect_vscode_editors`'s candidate list: each one means something only as
142    /// this table's row. A row may be empty on every axis — the editor is then
143    /// reachable by name but never claimed as detected — so a contributor adding
144    /// a target owes this table nothing.
145    fn traces(self) -> Traces {
146        const NONE: Traces = Traces {
147            home: &[],
148            repo: &[],
149            extension: None,
150        };
151        match self {
152            AgentEditor::Cursor => Traces {
153                home: &[".cursor"],
154                repo: &[".cursor"],
155                ..NONE
156            },
157            AgentEditor::Windsurf => Traces {
158                home: &[".codeium/windsurf", ".windsurf"],
159                repo: &[".windsurf"],
160                ..NONE
161            },
162            // `.gemini` alone is not evidence of the Gemini CLI: Antigravity keeps
163            // its own state under `.gemini/antigravity*` on a machine that has
164            // never run the CLI, which is why Antigravity claims that subtree and
165            // Gemini claims only the CLI's own settings file.
166            AgentEditor::Antigravity => Traces {
167                home: &[".antigravity-ide", ".gemini/antigravity"],
168                repo: &[".agent"],
169                ..NONE
170            },
171            AgentEditor::Cline => Traces {
172                repo: &[".clinerules"],
173                extension: Some("saoudrizwan.claude-dev"),
174                ..NONE
175            },
176            // Roo Code kept its original extension ID when it renamed, so the
177            // prefix stops before the `-cline` suffix to survive another rename.
178            AgentEditor::Roo => Traces {
179                repo: &[".roo"],
180                extension: Some("rooveterinaryinc.roo"),
181                ..NONE
182            },
183            AgentEditor::Kilocode => Traces {
184                repo: &[".kilocode"],
185                extension: Some("kilocode.kilo-code"),
186                ..NONE
187            },
188            AgentEditor::Continue => Traces {
189                home: &[".continue"],
190                repo: &[".continue"],
191                extension: Some("continue.continue"),
192            },
193            AgentEditor::AmazonQ => Traces {
194                home: &[".aws/amazonq"],
195                repo: &[".amazonq"],
196                extension: Some("amazonwebservices.amazon-q-vscode"),
197            },
198            AgentEditor::Kiro => Traces {
199                home: &[".kiro"],
200                repo: &[".kiro"],
201                ..NONE
202            },
203            AgentEditor::Trae => Traces {
204                home: &[".trae"],
205                repo: &[".trae"],
206                ..NONE
207            },
208            AgentEditor::Junie => Traces {
209                repo: &[".junie"],
210                ..NONE
211            },
212            AgentEditor::Gemini => Traces {
213                home: &[".gemini/settings.json"],
214                repo: &["GEMINI.md"],
215                ..NONE
216            },
217            AgentEditor::Zed => Traces {
218                home: &[".config/zed", "AppData/Roaming/Zed"],
219                repo: &[".rules"],
220                ..NONE
221            },
222            // `.copilot` is the Copilot CLI's home directory; the extension covers
223            // the editor-hosted install.
224            AgentEditor::Copilot => Traces {
225                home: &[".copilot"],
226                repo: &[".github/copilot-instructions.md"],
227                extension: Some("github.copilot"),
228            },
229            AgentEditor::Aider => Traces {
230                home: &[".aider.conf.yml"],
231                repo: &[".aider.conf.yml"],
232                ..NONE
233            },
234            AgentEditor::AgentsMd => Traces {
235                home: &[".codex", ".config/opencode", ".opencode"],
236                repo: &["AGENTS.md"],
237                ..NONE
238            },
239        }
240    }
241
242    fn detected_under(self, home: &std::path::Path, repo: Option<&std::path::Path>) -> bool {
243        let traces = self.traces();
244        traces.home.iter().any(|t| home.join(t).exists())
245            || traces
246                .extension
247                .is_some_and(|prefix| extension_present(home, prefix))
248            || repo.is_some_and(|r| traces.repo.iter().any(|t| r.join(t).exists()))
249    }
250
251    /// Whether `repo` already carries this editor's rules, and whether they match
252    /// the ones this binary would write — the same current-or-stale distinction
253    /// `stale_skill_copies` draws for the global SKILL.md installs.
254    fn rules_state(self, repo: &std::path::Path) -> RulesState {
255        let (relative, style) = self.target();
256        let Ok(existing) = fs::read_to_string(repo.join(relative)) else {
257            return RulesState::Missing;
258        };
259        match style {
260            Style::OwnFile | Style::CursorMdc => {
261                if existing.contains(EMBEDDED_RULES_MD) {
262                    RulesState::Current
263                } else {
264                    RulesState::Stale
265                }
266            }
267            Style::MarkedBlock => {
268                if !existing.contains(crate::constants::RULES_BLOCK_START) {
269                    RulesState::Missing
270                } else if existing.contains(EMBEDDED_RULES_MD) {
271                    RulesState::Current
272                } else {
273                    RulesState::Stale
274                }
275            }
276        }
277    }
278
279    /// The value `--agent` takes for this editor, exactly as clap will parse it —
280    /// derived rather than restated, so the report cannot suggest a spelling the
281    /// parser refuses.
282    fn flag_value(self) -> String {
283        use clap::ValueEnum;
284        self.to_possible_value()
285            .expect("no skipped variants")
286            .get_name()
287            .to_string()
288    }
289}
290
291/// See [`AgentEditor::traces`].
292struct Traces {
293    /// Paths relative to the home directory; a file or a directory, either counts.
294    home: &'static [&'static str],
295    /// Paths relative to the repository root — evidence the *team* uses the editor
296    /// even when this machine has never run it.
297    repo: &'static [&'static str],
298    /// An extension ID prefix to look for under every VS Code-family editor's
299    /// extension directory.
300    extension: Option<&'static str>,
301}
302
303/// Whether a repository's rules file is the one this binary would write.
304enum RulesState {
305    Current,
306    Stale,
307    Missing,
308}
309
310/// Where every VS Code-family editor unpacks its installed extensions, relative to
311/// the home directory. A miss costs one failed directory read, so listing a fork
312/// nobody has is free — the same reasoning as `detect_vscode_editors`.
313const EXTENSION_ROOTS: &[&str] = &[
314    ".vscode/extensions",
315    ".vscode-insiders/extensions",
316    ".vscode-oss/extensions",
317    ".antigravity-ide/extensions",
318    ".cursor/extensions",
319    ".windsurf/extensions",
320    ".trae/extensions",
321    ".kiro/extensions",
322];
323
324/// Whether any installed extension's directory name starts with `prefix`.
325///
326/// Anchored at the publisher, not a substring match: `nvidia.nsight-copilot` must
327/// not read as GitHub Copilot. Extension directories are named
328/// `<publisher>.<name>-<version>`, lowercased by the editor, so the prefix is
329/// compared against the lowercased name.
330fn extension_present(home: &std::path::Path, prefix: &str) -> bool {
331    EXTENSION_ROOTS.iter().any(|root| {
332        fs::read_dir(home.join(root)).is_ok_and(|entries| {
333            entries.flatten().any(|entry| {
334                entry
335                    .file_name()
336                    .to_string_lossy()
337                    .to_ascii_lowercase()
338                    .starts_with(prefix)
339            })
340        })
341    })
342}
343
344/// Every editor whose traces are on this machine — or, when `repo` is given, in
345/// that repository. Both roots come in as parameters so tests can hand in a
346/// fabricated machine instead of reading the real one.
347pub fn detect_editors(home: &std::path::Path, repo: Option<&std::path::Path>) -> Vec<AgentEditor> {
348    use clap::ValueEnum;
349    AgentEditor::value_variants()
350        .iter()
351        .copied()
352        .filter(|editor| editor.detected_under(home, repo))
353        .collect()
354}
355
356/// Run `devp skill` to export SKILL.md, report the detected editors and display AI
357/// Agent onboarding prompts; `devp skill --agent <editor>` writes per-repository
358/// rules for one editor, and `devp skill --detected` writes them for every editor
359/// the report would list.
360pub fn run(agent: Option<AgentEditor>, detected: bool) -> Result<()> {
361    if let Some(editor) = agent {
362        return write_agent_rules(editor);
363    }
364    if detected {
365        return write_detected_rules();
366    }
367    output::print_header("dev-prune AI Agent Skill Integration");
368
369    // The export is the command's one job — claiming success over a swallowed write
370    // error would leave the user pointing an agent at a file that is not there.
371    let skill_path = {
372        let config_dir =
373            Registry::config_dir().context("could not resolve the config directory")?;
374        fs::create_dir_all(&config_dir)
375            .with_context(|| format!("could not create {}", output::clean_path(&config_dir)))?;
376        let target = config_dir.join("SKILL.md");
377        fs::write(&target, EMBEDDED_SKILL_MD)
378            .with_context(|| format!("could not write {}", output::clean_path(&target)))?;
379        output::clean_path(&target)
380    };
381
382    output::print_success(&format!("Bundled SKILL.md exported to `{skill_path}`"));
383
384    // Agents with an on-disk skill format get the file put where they read it, so the
385    // prompts below are only needed for the ones without one.
386    let agent_roots = crate::setup::agent_skill_roots();
387    match crate::setup::ensure_agent_skills() {
388        crate::setup::Outcome::Installed | crate::setup::Outcome::AlreadyPresent => {
389            for root in &agent_roots {
390                output::print_success(&format!(
391                    "Skill installed for your AI agent at `{}`",
392                    output::clean_path(root.join("SKILL.md"))
393                ));
394            }
395        }
396        crate::setup::Outcome::Skipped(_) => {
397            output::print_info(
398                "No AI agent skills directory was found — use the prompts below instead.",
399            );
400        }
401        crate::setup::Outcome::Failed(why) => {
402            output::print_warning(&format!(
403                "Could not install into the agent skills directory: {why}"
404            ));
405        }
406    }
407    print_detection_report();
408    println!();
409    output::print_header("🤖 AI Agent Onboarding Prompts (Copy & Paste to your AI Assistant)");
410    println!();
411    output::print_info("Prompt 1: Initial Workspace Discovery & Onboarding");
412    println!("```markdown");
413    println!(
414        "Read the dev-prune AI skill at file://{skill_path} and run `devp init` to scan, register, and onboard all Git repositories in my workspace."
415    );
416    println!("```");
417    println!();
418    output::print_info(
419        "Prompt 2: Universal Skill Import (Antigravity, Claude Code, Cursor, Windsurf, Copilot, OpenClaw)",
420    );
421    println!("```markdown");
422    println!(
423        "I have installed `dev-prune` on my machine. Read the skill at file://{skill_path} and import it into your agent skills folder so you can autonomously maintain lockfiles and prune bloat directories."
424    );
425    println!("```");
426
427    Ok(())
428}
429
430/// Both writing flags need a repository to write into; the message names the flag
431/// the user actually typed.
432fn require_repo(cwd: &std::path::Path, flag: &str) -> Result<()> {
433    if !crate::scanner::is_git_repo(cwd) {
434        anyhow::bail!(
435            "`{flag}` writes rules into a repository, and the current directory is not \
436             one. Run it from the repository root."
437        );
438    }
439    Ok(())
440}
441
442/// Write the condensed rules into the current repository, in `editor`'s format.
443///
444/// Per-repository by design: these files are meant to be committed so the whole team's
445/// agents pick them up, which is exactly why nothing here is written unasked — this
446/// runs only when the user types the flag.
447fn write_agent_rules(editor: AgentEditor) -> Result<()> {
448    let cwd = std::env::current_dir().context("could not read the current directory")?;
449    require_repo(&cwd, "--agent")?;
450    write_rules_for(editor, &cwd)?;
451    print_commit_note();
452    Ok(())
453}
454
455/// Write rules for every editor detected on this machine or in this repository —
456/// the ones plain `devp skill` reports. Same consent shape as `--agent`: the report
457/// only ever prints, and typing the flag is what authorises the writes.
458fn write_detected_rules() -> Result<()> {
459    let cwd = std::env::current_dir().context("could not read the current directory")?;
460    require_repo(&cwd, "--detected")?;
461    let home = dirs::home_dir().context("could not resolve the home directory")?;
462    let editors = detect_editors(&home, Some(&cwd));
463    if editors.is_empty() {
464        output::print_info(
465            "No editor left a trace on this machine or in this repository — nothing to \
466             write. `devp skill --agent <editor>` writes rules for one by name; `devp \
467             skill --help` lists them.",
468        );
469        return Ok(());
470    }
471    for editor in editors {
472        write_rules_for(editor, &cwd)?;
473    }
474    print_commit_note();
475    Ok(())
476}
477
478fn write_rules_for(editor: AgentEditor, cwd: &std::path::Path) -> Result<()> {
479    let (relative, style) = editor.target();
480    let target = cwd.join(relative);
481    let content = match style {
482        Style::OwnFile => EMBEDDED_RULES_MD.to_string(),
483        Style::CursorMdc => format!(
484            "---\ndescription: dev-prune (devp) — reclaiming disk space from idle \
485             repositories safely\nalwaysApply: false\n---\n\n{EMBEDDED_RULES_MD}"
486        ),
487        Style::MarkedBlock => {
488            let existing = fs::read_to_string(&target).unwrap_or_default();
489            upsert_marked_block(&existing)
490        }
491    };
492    write_rules_file(&target, &content)?;
493
494    output::print_success(&format!("Rules written: {}", output::clean_path(&target)));
495    if let Some(wiring) = editor.wiring() {
496        output::print_info(wiring);
497    }
498    Ok(())
499}
500
501fn print_commit_note() {
502    output::print_info(
503        "Commit the file if the whole team's agents should have it; it is inert data \
504         and safe to share.",
505    );
506}
507
508/// The read-only half of detection: which editors this machine or repository shows
509/// traces of, whether their rules are already written here, and the command that
510/// writes them. Printed by the bare `devp skill` run; `--detected` acts on the
511/// same list.
512fn print_detection_report() {
513    // No home directory means no baseline to detect against; the bare run has other
514    // jobs, so the report just stays silent rather than failing them.
515    let Some(home) = dirs::home_dir() else {
516        return;
517    };
518    let repo = std::env::current_dir()
519        .ok()
520        .filter(|d| crate::scanner::is_git_repo(d));
521    let editors = detect_editors(&home, repo.as_deref());
522    if editors.is_empty() {
523        return;
524    }
525    println!();
526    output::print_header("Detected editors and agents");
527    let rows: Vec<(String, &'static str, String)> = editors
528        .iter()
529        .map(|editor| {
530            let state = match &repo {
531                Some(r) => match editor.rules_state(r) {
532                    RulesState::Current => "rules current here",
533                    RulesState::Stale => "rules stale here",
534                    RulesState::Missing => "no rules here yet",
535                },
536                None => "",
537            };
538            (
539                editor.label().to_string(),
540                state,
541                format!("devp skill --agent {}", editor.flag_value()),
542            )
543        })
544        .collect();
545    let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
546    let state_width = rows.iter().map(|(_, s, _)| s.len()).max().unwrap_or(0);
547    for (label, state, command) in &rows {
548        println!("  {label:<label_width$}  {state:<state_width$}  {command}");
549    }
550    if repo.is_some() {
551        output::print_info(
552            "`devp skill --detected` writes rules for all of them into this repository \
553             in one pass.",
554        );
555    } else {
556        output::print_info(
557            "Run `devp skill --detected` from a repository root to write rules for all \
558             of them in one pass.",
559        );
560    }
561}
562
563/// Replace dev-prune's marked block in `existing`, or append one — leaving every
564/// byte outside the markers exactly as found.
565fn upsert_marked_block(existing: &str) -> String {
566    let block = format!(
567        "{}\n{EMBEDDED_RULES_MD}{}\n",
568        crate::constants::RULES_BLOCK_START,
569        crate::constants::RULES_BLOCK_END
570    );
571    match (
572        existing.find(crate::constants::RULES_BLOCK_START),
573        existing.find(crate::constants::RULES_BLOCK_END),
574    ) {
575        (Some(start), Some(end)) if end > start => {
576            let after = end + crate::constants::RULES_BLOCK_END.len();
577            // The trailing newline of the old block belongs to it.
578            let after = if existing[after..].starts_with('\n') {
579                after + 1
580            } else {
581                after
582            };
583            format!("{}{block}{}", &existing[..start], &existing[after..])
584        }
585        _ if existing.is_empty() => block,
586        _ => format!("{}\n\n{block}", existing.trim_end_matches('\n')),
587    }
588}
589
590fn write_rules_file(target: &std::path::Path, content: &str) -> Result<()> {
591    if let Some(parent) = target.parent() {
592        fs::create_dir_all(parent)
593            .with_context(|| format!("could not create {}", output::clean_path(parent)))?;
594    }
595    fs::write(target, content)
596        .with_context(|| format!("could not write {}", output::clean_path(target)))
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use clap::ValueEnum;
603
604    #[test]
605    fn every_editor_writes_to_its_own_file() {
606        // A copy-pasted path would make one editor silently overwrite another's rules,
607        // and nothing else in the program would notice.
608        let mut paths: Vec<&str> = AgentEditor::value_variants()
609            .iter()
610            .map(|e| e.target().0)
611            .collect();
612        let total = paths.len();
613        paths.sort_unstable();
614        paths.dedup();
615        assert_eq!(paths.len(), total, "two editors share a path");
616    }
617
618    #[test]
619    fn the_editor_that_has_to_be_told_to_read_its_file_says_so() {
620        // Rules an agent never loads are worse than no rules at all: the repository
621        // looks configured and nothing is. Aider is the only target whose file is not
622        // picked up by being there, so it is the only one that carries a note — and the
623        // note has to name the file, because that name is what goes in the config.
624        for editor in AgentEditor::value_variants() {
625            if let Some(note) = editor.wiring() {
626                let path = editor.target().0;
627                assert_eq!(path, crate::constants::AIDER_CONVENTIONS_FILE);
628                assert!(
629                    note.contains(path),
630                    "the note does not name the file: {note}"
631                );
632            }
633        }
634        assert!(
635            AgentEditor::Aider.wiring().is_some(),
636            "aider writes a file nothing reads until it is configured"
637        );
638    }
639
640    #[test]
641    fn a_shared_file_is_only_ever_edited_inside_the_markers() {
642        // The whole reason `MarkedBlock` exists: these files belong to the user, and a
643        // second run must not stack a second copy of the rules on top of the first.
644        let theirs = "# Our conventions\n\nUse tabs.\n";
645        let once = upsert_marked_block(theirs);
646        let twice = upsert_marked_block(&once);
647        assert_eq!(once, twice, "a second write duplicated the block");
648        assert!(once.starts_with(theirs));
649        assert_eq!(once.matches(crate::constants::RULES_BLOCK_START).count(), 1);
650    }
651
652    // Detection takes its roots as parameters precisely so these tests can build a
653    // machine out of a temp directory — none of them reads the real home directory.
654
655    #[test]
656    fn a_home_trace_is_enough() {
657        let home = tempfile::tempdir().unwrap();
658        std::fs::create_dir_all(home.path().join(".continue")).unwrap();
659        let found = detect_editors(home.path(), None);
660        assert!(
661            found.iter().any(|e| matches!(e, AgentEditor::Continue)),
662            "a `.continue` home directory did not detect Continue"
663        );
664    }
665
666    #[test]
667    fn a_bare_machine_detects_nothing() {
668        let home = tempfile::tempdir().unwrap();
669        assert!(detect_editors(home.path(), None).is_empty());
670    }
671
672    #[test]
673    fn an_extension_counts_only_by_publisher_prefix() {
674        // The one real near-miss on record: `nvidia.nsight-copilot` sits in the same
675        // extensions directory and contains "copilot", but it is not GitHub Copilot.
676        let home = tempfile::tempdir().unwrap();
677        let ext = home.path().join(".vscode/extensions");
678        std::fs::create_dir_all(ext.join("nvidia.nsight-copilot-2026.1.21-win32-x64")).unwrap();
679        assert!(
680            !detect_editors(home.path(), None)
681                .iter()
682                .any(|e| matches!(e, AgentEditor::Copilot)),
683            "a third-party extension containing 'copilot' read as GitHub Copilot"
684        );
685
686        std::fs::create_dir_all(ext.join("github.copilot-1.350.0")).unwrap();
687        assert!(
688            detect_editors(home.path(), None)
689                .iter()
690                .any(|e| matches!(e, AgentEditor::Copilot)),
691            "the real github.copilot extension was not detected"
692        );
693    }
694
695    #[test]
696    fn repo_traces_apply_only_when_a_repository_is_given() {
697        let home = tempfile::tempdir().unwrap();
698        let repo = tempfile::tempdir().unwrap();
699        std::fs::write(repo.path().join("GEMINI.md"), "# rules\n").unwrap();
700        assert!(
701            detect_editors(home.path(), None).is_empty(),
702            "a repo trace was counted with no repository in play"
703        );
704        assert!(
705            detect_editors(home.path(), Some(repo.path()))
706                .iter()
707                .any(|e| matches!(e, AgentEditor::Gemini)),
708            "GEMINI.md in the repository did not detect the Gemini CLI"
709        );
710    }
711
712    #[test]
713    fn rules_state_tells_missing_from_stale_from_current() {
714        let repo = tempfile::tempdir().unwrap();
715        let editor = AgentEditor::Windsurf;
716        assert!(matches!(
717            editor.rules_state(repo.path()),
718            RulesState::Missing
719        ));
720
721        let target = repo.path().join(editor.target().0);
722        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
723        std::fs::write(&target, "an older release's rules\n").unwrap();
724        assert!(matches!(editor.rules_state(repo.path()), RulesState::Stale));
725
726        std::fs::write(&target, EMBEDDED_RULES_MD).unwrap();
727        assert!(matches!(
728            editor.rules_state(repo.path()),
729            RulesState::Current
730        ));
731    }
732
733    #[test]
734    fn a_shared_file_without_the_markers_counts_as_missing() {
735        // AGENTS.md full of the user's own text is not dev-prune rules going stale;
736        // it is a file dev-prune has never written into.
737        let repo = tempfile::tempdir().unwrap();
738        std::fs::write(repo.path().join("AGENTS.md"), "# Their agents file\n").unwrap();
739        assert!(matches!(
740            AgentEditor::AgentsMd.rules_state(repo.path()),
741            RulesState::Missing
742        ));
743    }
744}