1use serde::{Deserialize, Serialize};
13
14use crate::error::RepographError;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum AgentId {
24 ClaudeCode,
26 AgentsMd,
28 Cursor,
30 Aider,
32 Windsurf,
34 Copilot,
36}
37
38impl AgentId {
39 #[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 #[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 #[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 #[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 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 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}