Skip to main content

agent_plugins/
name.rs

1//! The plugin name, as constrained by §5.5.
2//!
3//! `PluginName` follows *parse, don't validate*: the only way to obtain one is
4//! through a parse that enforces every constraint of §5.5, so any function
5//! receiving a `PluginName` may treat validity as settled.
6
7use std::fmt;
8
9/// A validated plugin name (§5.5): 1–64 characters of `a-z`, `0-9`, `-`, `.`;
10/// alphanumeric at both ends; no `--` or `..` runs.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct PluginName(String);
13
14/// Exactly which §5.5 constraint a candidate name broke.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InvalidName {
17    /// The name is empty.
18    Empty,
19    /// The name exceeds 64 characters.
20    TooLong {
21        /// The offending length, in characters.
22        length: usize,
23    },
24    /// A character outside `a-z`, `0-9`, `-`, `.` appears at `at`.
25    ForbiddenCharacter {
26        /// The forbidden character.
27        character: char,
28        /// Its byte offset in the candidate.
29        at: usize,
30    },
31    /// The first or last character is not alphanumeric.
32    EdgeNotAlphanumeric,
33    /// The name contains `--` or `..`.
34    DoubledSeparator {
35        /// The repeated separator: `-` or `.`.
36        separator: char,
37    },
38}
39
40impl fmt::Display for InvalidName {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::Empty => f.write_str("plugin name is empty"),
44            Self::TooLong { length } => {
45                write!(f, "plugin name is {length} characters long; the limit is 64")
46            }
47            Self::ForbiddenCharacter { character, at } => write!(
48                f,
49                "character {character:?} at position {at} is outside `a-z`, `0-9`, `-`, `.`"
50            ),
51            Self::EdgeNotAlphanumeric => {
52                f.write_str("plugin name must start and end with a lowercase letter or digit")
53            }
54            Self::DoubledSeparator { separator } => {
55                write!(f, "plugin name must not repeat {separator:?}")
56            }
57        }
58    }
59}
60
61impl std::error::Error for InvalidName {}
62
63impl PluginName {
64    /// Parse a candidate against every constraint of §5.5.
65    pub fn parse(candidate: &str) -> Result<Self, InvalidName> {
66        if candidate.is_empty() {
67            return Err(InvalidName::Empty);
68        }
69        let length = candidate.chars().count();
70        if length > 64 {
71            return Err(InvalidName::TooLong { length });
72        }
73        for (at, character) in candidate.char_indices() {
74            if !matches!(character, 'a'..='z' | '0'..='9' | '-' | '.') {
75                return Err(InvalidName::ForbiddenCharacter { character, at });
76            }
77        }
78        let edges_ok = candidate.chars().next().zip(candidate.chars().next_back()).is_some_and(
79            |(first, last)| first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric(),
80        );
81        if !edges_ok {
82            return Err(InvalidName::EdgeNotAlphanumeric);
83        }
84        if candidate.contains("--") {
85            return Err(InvalidName::DoubledSeparator { separator: '-' });
86        }
87        if candidate.contains("..") {
88            return Err(InvalidName::DoubledSeparator { separator: '.' });
89        }
90        Ok(Self(candidate.to_owned()))
91    }
92
93    /// The validated name.
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98
99impl fmt::Display for PluginName {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str(&self.0)
102    }
103}
104
105impl AsRef<str> for PluginName {
106    fn as_ref(&self) -> &str {
107        &self.0
108    }
109}
110
111impl std::str::FromStr for PluginName {
112    type Err = InvalidName;
113
114    fn from_str(s: &str) -> Result<Self, Self::Err> {
115        Self::parse(s)
116    }
117}
118
119impl TryFrom<String> for PluginName {
120    type Error = InvalidName;
121
122    fn try_from(value: String) -> Result<Self, Self::Error> {
123        Self::parse(&value)
124    }
125}
126
127impl PartialEq<str> for PluginName {
128    fn eq(&self, other: &str) -> bool {
129        self.0 == other
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn spec_examples() {
139        // Valid names straight from §5.5.
140        for ok in ["my-plugin", "acme.tools", "lint3r", "a"] {
141            assert!(PluginName::parse(ok).is_ok(), "{ok} should be valid");
142        }
143        // Invalid names straight from §5.5.
144        assert_eq!(
145            PluginName::parse("My-Plugin"),
146            Err(InvalidName::ForbiddenCharacter { character: 'M', at: 0 })
147        );
148        assert_eq!(PluginName::parse("-start"), Err(InvalidName::EdgeNotAlphanumeric));
149        assert_eq!(
150            PluginName::parse("has--double"),
151            Err(InvalidName::DoubledSeparator { separator: '-' })
152        );
153        assert_eq!(
154            PluginName::parse("too.many..dots"),
155            Err(InvalidName::DoubledSeparator { separator: '.' })
156        );
157        assert_eq!(PluginName::parse(""), Err(InvalidName::Empty));
158    }
159
160    #[test]
161    fn length_limits() {
162        assert!(PluginName::parse(&"a".repeat(64)).is_ok());
163        assert_eq!(PluginName::parse(&"a".repeat(65)), Err(InvalidName::TooLong { length: 65 }));
164    }
165}