1use std::fmt;
10
11pub const RESERVED_WORDS: [&str; 13] = [
13 "agents",
14 "profiles",
15 "status",
16 "list",
17 "create",
18 "delete",
19 "current",
20 "resolve",
21 "doctor",
22 "link",
23 "unlink",
24 "repositories",
25 "completions",
26];
27
28const WINDOWS_DEVICE_NAMES: [&str; 22] = [
30 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
31 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
32];
33
34pub fn is_reserved_word(word: &str) -> bool {
36 RESERVED_WORDS.iter().any(|reserved| reserved.eq_ignore_ascii_case(word))
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Platform {
42 Unix,
43 Windows,
44}
45
46impl Platform {
47 pub fn host() -> Platform {
49 if cfg!(windows) { Platform::Windows } else { Platform::Unix }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum InvalidReason {
56 Empty,
57 BadFirstChar(char),
58 BadChar(char),
59 Reserved(&'static str),
60 WindowsDeviceName,
61 WindowsTrailingDot,
62}
63
64impl fmt::Display for InvalidReason {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 InvalidReason::Empty => write!(f, "a profile name must not be empty"),
68 InvalidReason::BadFirstChar(c) => {
69 write!(f, "a profile name must begin with an ASCII letter or digit, not {c:?}")
70 }
71 InvalidReason::BadChar(c) => write!(
72 f,
73 "a profile name may contain only ASCII letters, digits, '.', '_' and '-', not {c:?}"
74 ),
75 InvalidReason::Reserved(word) => {
76 write!(f, "\"{word}\" is a reserved command word and cannot be a profile name")
77 }
78 InvalidReason::WindowsDeviceName => {
79 write!(f, "the name is a reserved Windows device name")
80 }
81 InvalidReason::WindowsTrailingDot => {
82 write!(f, "on Windows a profile name must not end with a dot")
83 }
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
90pub struct ProfileName(String);
91
92impl ProfileName {
93 pub fn parse(name: &str, platform: Platform) -> Result<ProfileName, InvalidReason> {
95 let mut chars = name.chars();
96 let first = chars.next().ok_or(InvalidReason::Empty)?;
97 if !first.is_ascii_alphanumeric() {
98 return Err(InvalidReason::BadFirstChar(first));
99 }
100 if let Some(bad) =
101 chars.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
102 {
103 return Err(InvalidReason::BadChar(bad));
104 }
105 if let Some(reserved) = RESERVED_WORDS.iter().find(|word| word.eq_ignore_ascii_case(name)) {
106 return Err(InvalidReason::Reserved(reserved));
107 }
108 if platform == Platform::Windows {
109 let stem = name.split('.').next().unwrap_or(name);
110 if WINDOWS_DEVICE_NAMES.iter().any(|device| device.eq_ignore_ascii_case(stem)) {
111 return Err(InvalidReason::WindowsDeviceName);
112 }
113 if name.ends_with('.') {
114 return Err(InvalidReason::WindowsTrailingDot);
115 }
116 }
117 Ok(ProfileName(name.to_owned()))
118 }
119
120 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123}
124
125impl fmt::Display for ProfileName {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 f.write_str(&self.0)
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
133pub struct AgentId(String);
134
135impl AgentId {
136 pub fn parse(id: &str) -> Option<AgentId> {
138 let mut chars = id.chars();
139 let first = chars.next()?;
140 let valid = first.is_ascii_lowercase()
141 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
142 valid.then(|| AgentId(id.to_owned()))
143 }
144
145 pub fn as_str(&self) -> &str {
146 &self.0
147 }
148}
149
150impl fmt::Display for AgentId {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 f.write_str(&self.0)
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn unix(name: &str) -> Result<ProfileName, InvalidReason> {
161 ProfileName::parse(name, Platform::Unix)
162 }
163
164 fn windows(name: &str) -> Result<ProfileName, InvalidReason> {
165 ProfileName::parse(name, Platform::Windows)
166 }
167
168 #[test]
169 fn valid_names_are_accepted_on_both_platforms() {
170 for name in ["work", "Work", "w", "7", "a.b", "a_b", "a-b", "personal-2", "WORK.v1_x-y"] {
171 assert_eq!(unix(name).unwrap().as_str(), name);
172 assert_eq!(windows(name).unwrap().as_str(), name);
173 }
174 }
175
176 #[test]
177 fn empty_name_is_rejected() {
178 assert_eq!(unix(""), Err(InvalidReason::Empty));
179 assert_eq!(windows(""), Err(InvalidReason::Empty));
180 }
181
182 #[test]
183 fn name_must_begin_with_letter_or_digit() {
184 for (name, first) in
185 [(".a", '.'), ("_a", '_'), ("-a", '-'), (".", '.'), ("..", '.'), (" a", ' ')]
186 {
187 assert_eq!(unix(name), Err(InvalidReason::BadFirstChar(first)), "{name:?}");
188 assert_eq!(windows(name), Err(InvalidReason::BadFirstChar(first)), "{name:?}");
189 }
190 }
191
192 #[test]
193 fn forbidden_characters_are_rejected() {
194 for (name, bad) in [
195 ("a/b", '/'),
196 ("a\\b", '\\'),
197 ("a b", ' '),
198 ("a\tb", '\t'),
199 ("a\0b", '\0'),
200 ("a\u{7f}", '\u{7f}'),
201 ("a:b", ':'),
202 ("a*b", '*'),
203 ("x\u{e9}", '\u{e9}'),
204 ] {
205 assert_eq!(unix(name), Err(InvalidReason::BadChar(bad)), "{name:?}");
206 assert_eq!(windows(name), Err(InvalidReason::BadChar(bad)), "{name:?}");
207 }
208 }
209
210 #[test]
211 fn reserved_words_are_rejected_in_any_case() {
212 for word in RESERVED_WORDS {
213 let upper = word.to_ascii_uppercase();
214 let mixed: String = word
215 .chars()
216 .enumerate()
217 .map(|(i, c)| if i % 2 == 0 { c.to_ascii_uppercase() } else { c })
218 .collect();
219 for candidate in [word.to_string(), upper, mixed] {
220 assert_eq!(unix(&candidate), Err(InvalidReason::Reserved(word)), "{candidate}");
221 assert_eq!(windows(&candidate), Err(InvalidReason::Reserved(word)), "{candidate}");
222 }
223 }
224 }
225
226 #[test]
227 fn windows_device_names_are_rejected_only_on_windows() {
228 let mut names: Vec<String> =
229 ["CON", "PRN", "AUX", "NUL"].iter().map(|s| s.to_string()).collect();
230 for n in 1..=9 {
231 names.push(format!("COM{n}"));
232 names.push(format!("LPT{n}"));
233 }
234 for name in names {
235 for candidate in [
236 name.clone(),
237 name.to_ascii_lowercase(),
238 format!("{name}.txt"),
239 format!("{name}.a.b"),
240 ] {
241 assert_eq!(
242 windows(&candidate),
243 Err(InvalidReason::WindowsDeviceName),
244 "{candidate}"
245 );
246 assert!(unix(&candidate).is_ok(), "{candidate}");
247 }
248 }
249 for not_device in ["CONSOLE", "COM10", "LPT0", "NULL", "AUXX"] {
250 assert!(windows(not_device).is_ok(), "{not_device}");
251 }
252 }
253
254 #[test]
255 fn trailing_dot_is_rejected_only_on_windows() {
256 assert_eq!(windows("work."), Err(InvalidReason::WindowsTrailingDot));
257 assert!(unix("work.").is_ok());
258 }
259
260 #[test]
261 fn agent_id_syntax() {
262 for id in ["fake", "claude", "a", "gemini-cli", "a1-2"] {
263 assert_eq!(AgentId::parse(id).unwrap().as_str(), id);
264 }
265 for id in ["", "Fake", "1a", "-a", "a_b", "a.b", "a b", "\u{e9}"] {
266 assert!(AgentId::parse(id).is_none(), "{id:?}");
267 }
268 }
269
270 #[test]
271 fn is_reserved_word_ignores_ascii_case() {
272 assert!(is_reserved_word("CREATE"));
273 assert!(is_reserved_word("Link"));
274 assert!(!is_reserved_word("work"));
275 }
276}