Skip to main content

agent_profile/
name.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! Profile-name validation (spec §6) and the reserved command words (spec §5.3).
8
9use std::fmt;
10
11/// The v0.1 reserved command words (spec §5.3). A profile name must not equal one, ignoring ASCII case.
12pub 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
28/// Windows device names (spec §6), rejected with or without an extension.
29const 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
34/// Whether `word` is a reserved command word, ignoring ASCII case.
35pub fn is_reserved_word(word: &str) -> bool {
36    RESERVED_WORDS.iter().any(|reserved| reserved.eq_ignore_ascii_case(word))
37}
38
39/// Which rule set applies. The Windows-only rules of spec §6 are a parameter so every OS tests both.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Platform {
42    Unix,
43    Windows,
44}
45
46impl Platform {
47    /// The platform this binary runs on.
48    pub fn host() -> Platform {
49        if cfg!(windows) { Platform::Windows } else { Platform::Unix }
50    }
51}
52
53/// Why a profile name is invalid.
54#[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/// A validated profile name (spec §6).
89#[derive(Debug, Clone, PartialEq, Eq, Hash)]
90pub struct ProfileName(String);
91
92impl ProfileName {
93    /// Validates `name` against every spec §6 rule for `platform`.
94    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/// An agent identifier: `[a-z][a-z0-9-]*`.
132#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
133pub struct AgentId(String);
134
135impl AgentId {
136    /// Returns `None` unless `id` matches `[a-z][a-z0-9-]*`.
137    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}