Skip to main content

repograph_core/
agents.rs

1//! Built-in registry of agent toolchains.
2//!
3//! Maps a small, hardcoded set of agent identifiers to the file patterns each
4//! agent uses to store its rules inside a repository. The set is intentionally
5//! not user-extensible — the contract is between `repograph` and the agent
6//! toolchain ecosystem, not between `repograph` and each user's preferences.
7//!
8//! Adding a new agent is a one-line enum extension plus its `file_patterns`
9//! arm. Removing one requires a deprecation period: one minor release where
10//! the ID is accepted with a `warn!` and routed to a no-op pattern set.
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::RepographError;
15
16/// One of the agent toolchains repograph knows how to find rules for.
17///
18/// Serialized as a kebab-case string in TOML (`claude-code`, `agents-md`, …)
19/// and JSON. Unknown IDs deserialize as a typed error via serde's default
20/// rejection of unknown enum variants.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum AgentId {
24    /// Anthropic's Claude Code — `CLAUDE.md` at repo root.
25    ClaudeCode,
26    /// Cross-vendor `AGENTS.md` convention.
27    AgentsMd,
28    /// Cursor — `.cursor/rules/*.md` and legacy `.cursorrules`.
29    Cursor,
30    /// Aider — `CONVENTIONS.md`.
31    Aider,
32    /// Windsurf — `.windsurfrules`.
33    Windsurf,
34    /// GitHub Copilot — `.github/copilot-instructions.md`.
35    Copilot,
36}
37
38impl AgentId {
39    /// All known agent IDs in the v1 registry, in display order.
40    #[must_use]
41    pub const fn all() -> &'static [Self] {
42        &[
43            Self::ClaudeCode,
44            Self::AgentsMd,
45            Self::Cursor,
46            Self::Aider,
47            Self::Windsurf,
48            Self::Copilot,
49        ]
50    }
51
52    /// The kebab-case identifier used in TOML / JSON / CLI flags.
53    #[must_use]
54    pub const fn as_str(&self) -> &'static str {
55        match self {
56            Self::ClaudeCode => "claude-code",
57            Self::AgentsMd => "agents-md",
58            Self::Cursor => "cursor",
59            Self::Aider => "aider",
60            Self::Windsurf => "windsurf",
61            Self::Copilot => "copilot",
62        }
63    }
64
65    /// A short human-readable label for UI rendering (cliclack option labels).
66    #[must_use]
67    pub const fn display_name(&self) -> &'static str {
68        match self {
69            Self::ClaudeCode => "Claude Code",
70            Self::AgentsMd => "AGENTS.md",
71            Self::Cursor => "Cursor",
72            Self::Aider => "Aider",
73            Self::Windsurf => "Windsurf",
74            Self::Copilot => "GitHub Copilot",
75        }
76    }
77
78    /// The glob-style file patterns this agent stores its rules at, relative
79    /// to a repository's root. Returned slice is always non-empty.
80    #[must_use]
81    pub const fn file_patterns(&self) -> &'static [&'static str] {
82        match self {
83            Self::ClaudeCode => &["CLAUDE.md"],
84            Self::AgentsMd => &["AGENTS.md"],
85            Self::Cursor => &[".cursor/rules/*.md", ".cursorrules"],
86            Self::Aider => &["CONVENTIONS.md"],
87            Self::Windsurf => &[".windsurfrules"],
88            Self::Copilot => &[".github/copilot-instructions.md"],
89        }
90    }
91
92    /// Parse a kebab-case agent ID string. Used by the `--agents` CLI flag.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`RepographError::InvalidName`] with `kind = "agent"` when the
97    /// input is not one of the v1 registry entries. The error maps to exit
98    /// code `2` (usage error) per the documented contract.
99    pub fn parse(s: &str) -> Result<Self, RepographError> {
100        for id in Self::all() {
101            if id.as_str() == s {
102                return Ok(*id);
103            }
104        }
105        Err(RepographError::InvalidName {
106            kind: "agent",
107            name: s.to_string(),
108            reason: "not a recognized agent ID",
109        })
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    #![allow(clippy::unwrap_used, clippy::items_after_statements)]
116    use super::*;
117
118    #[test]
119    fn all_contains_every_variant_exactly_once() {
120        let all = AgentId::all();
121        assert_eq!(all.len(), 6, "v1 registry has six entries");
122        let mut seen = std::collections::BTreeSet::new();
123        for id in all {
124            assert!(seen.insert(*id), "duplicate variant in all()");
125        }
126    }
127
128    #[test]
129    fn file_patterns_match_spec_table() {
130        assert_eq!(AgentId::ClaudeCode.file_patterns(), &["CLAUDE.md"]);
131        assert_eq!(AgentId::AgentsMd.file_patterns(), &["AGENTS.md"]);
132        assert_eq!(
133            AgentId::Cursor.file_patterns(),
134            &[".cursor/rules/*.md", ".cursorrules"]
135        );
136        assert_eq!(AgentId::Aider.file_patterns(), &["CONVENTIONS.md"]);
137        assert_eq!(AgentId::Windsurf.file_patterns(), &[".windsurfrules"]);
138        assert_eq!(
139            AgentId::Copilot.file_patterns(),
140            &[".github/copilot-instructions.md"]
141        );
142    }
143
144    #[test]
145    fn file_patterns_are_non_empty_for_every_id() {
146        for id in AgentId::all() {
147            assert!(
148                !id.file_patterns().is_empty(),
149                "{id:?} has empty file_patterns"
150            );
151        }
152    }
153
154    #[test]
155    fn parse_accepts_kebab_case_ids() {
156        assert_eq!(AgentId::parse("claude-code").unwrap(), AgentId::ClaudeCode);
157        assert_eq!(AgentId::parse("agents-md").unwrap(), AgentId::AgentsMd);
158        assert_eq!(AgentId::parse("cursor").unwrap(), AgentId::Cursor);
159        assert_eq!(AgentId::parse("aider").unwrap(), AgentId::Aider);
160        assert_eq!(AgentId::parse("windsurf").unwrap(), AgentId::Windsurf);
161        assert_eq!(AgentId::parse("copilot").unwrap(), AgentId::Copilot);
162    }
163
164    #[test]
165    fn parse_rejects_unknown_id_with_invalid_name_kind_agent() {
166        let err = AgentId::parse("bogus").unwrap_err();
167        match err {
168            RepographError::InvalidName { kind, name, .. } => {
169                assert_eq!(kind, "agent");
170                assert_eq!(name, "bogus");
171            }
172            other => panic!("expected InvalidName, got {other:?}"),
173        }
174    }
175
176    #[test]
177    fn parse_rejects_pascal_case() {
178        // Sanity: we only accept the on-the-wire kebab form.
179        assert!(AgentId::parse("ClaudeCode").is_err());
180    }
181
182    #[test]
183    fn parse_error_exit_code_is_2() {
184        let err = AgentId::parse("bogus").unwrap_err();
185        assert_eq!(err.exit_code(), 2);
186    }
187
188    #[test]
189    fn serde_round_trip_through_toml_value() {
190        let original = vec![AgentId::ClaudeCode, AgentId::Cursor, AgentId::AgentsMd];
191        let serialized = toml::to_string(&toml::Table::from_iter([(
192            "selected".to_string(),
193            toml::Value::try_from(&original).unwrap(),
194        )]))
195        .unwrap();
196        assert!(
197            serialized.contains("\"claude-code\""),
198            "kebab-case form on the wire, got: {serialized}"
199        );
200        assert!(serialized.contains("\"cursor\""));
201        assert!(serialized.contains("\"agents-md\""));
202
203        #[derive(Deserialize)]
204        struct Wrap {
205            selected: Vec<AgentId>,
206        }
207        let parsed: Wrap = toml::from_str(&serialized).unwrap();
208        assert_eq!(parsed.selected, original);
209    }
210
211    #[test]
212    fn serde_rejects_unknown_id() {
213        #[derive(Debug, Deserialize)]
214        struct Wrap {
215            #[allow(dead_code)]
216            selected: Vec<AgentId>,
217        }
218        let err = toml::from_str::<Wrap>("selected = [\"claude-code\", \"bogus\"]").unwrap_err();
219        let msg = err.to_string();
220        assert!(
221            msg.contains("bogus") || msg.contains("unknown variant"),
222            "unknown variant should be named, got: {msg}"
223        );
224    }
225
226    #[test]
227    fn as_str_round_trips_through_parse() {
228        for id in AgentId::all() {
229            assert_eq!(AgentId::parse(id.as_str()).unwrap(), *id);
230        }
231    }
232}