Skip to main content

qcode/provider/
tag.rs

1//! The tag a person gives a provider: the short name that stands in front of a model.
2//!
3//! A tag is checked rather than repaired. It is shown beside a model name, written into a
4//! profile and used to find a provider again, so a tag with a slash or a space in it would
5//! either read as something else or match nothing; the person is told which character stopped it
6//! and where, and changes it themselves.
7
8/// The longest a tag may be. A tag stands in front of a model name on one row, and a model name
9/// is already long; past this the row is the tag and nothing else.
10const LONGEST: usize = 24;
11
12/// Why a name cannot be a tag. Positions are counted from one, the way a person counts the
13/// letters of the name they just typed.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TagError {
16    /// Nothing was written.
17    Empty,
18    /// The name is longer than a tag may be.
19    TooLong {
20        /// How many characters were written.
21        length: usize,
22    },
23    /// The first character is not a letter, so the tag would not read as a name.
24    BadStart {
25        /// The character that was written first.
26        character: char,
27    },
28    /// A character a tag cannot hold.
29    Illegal {
30        /// Which character it is, counted from one.
31        position: usize,
32        /// The character itself.
33        character: char,
34    },
35}
36
37/// A provider's tag, checked.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct Tag(String);
40
41impl Tag {
42    /// The tag `name` spells, or the first reason it cannot be one.
43    ///
44    /// # Errors
45    ///
46    /// [`TagError`] says what stopped it and where.
47    pub fn parse(name: &str) -> Result<Self, TagError> {
48        let characters: Vec<char> = name.chars().collect();
49        let Some(first) = characters.first().copied() else { return Err(TagError::Empty) };
50        if characters.len() > LONGEST {
51            return Err(TagError::TooLong { length: characters.len() });
52        }
53        // Digits and dashes are allowed inside a tag but not in front of it: a name that starts
54        // with one reads as a number or an option rather than as the person's own word for a
55        // machine.
56        if !first.is_ascii_alphabetic() {
57            return Err(TagError::BadStart { character: first });
58        }
59        for (index, character) in characters.iter().copied().enumerate() {
60            if !(character.is_ascii_alphanumeric() || character == '-' || character == '_') {
61                return Err(TagError::Illegal { position: index + 1, character });
62            }
63        }
64        Ok(Self(name.to_owned()))
65    }
66
67    /// The tag as it is written.
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        &self.0
71    }
72}
73
74impl std::fmt::Display for Tag {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter.write_str(&self.0)
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn a_plain_name_is_a_tag_and_reads_back_as_itself() {
86        for name in ["ev", "ev-makinesi", "openrouter_2", "a"] {
87            let tag = Tag::parse(name).expect(name);
88            assert_eq!(tag.as_str(), name);
89            assert_eq!(tag.to_string(), name);
90        }
91    }
92
93    #[test]
94    fn a_character_that_would_read_as_something_else_is_refused_where_it_stands() {
95        // A slash is the one that matters most: the tag stands in front of a model name.
96        assert_eq!(Tag::parse("ev/makine"), Err(TagError::Illegal { position: 3, character: '/' }));
97        assert_eq!(Tag::parse("ev makine"), Err(TagError::Illegal { position: 3, character: ' ' }));
98        assert_eq!(Tag::parse("ev.makine"), Err(TagError::Illegal { position: 3, character: '.' }));
99        assert_eq!(Tag::parse("evÇ"), Err(TagError::Illegal { position: 3, character: 'Ç' }));
100    }
101
102    #[test]
103    fn a_tag_starts_with_a_letter_and_is_never_empty() {
104        assert_eq!(Tag::parse(""), Err(TagError::Empty));
105        assert_eq!(Tag::parse("2ev"), Err(TagError::BadStart { character: '2' }));
106        assert_eq!(Tag::parse("-ev"), Err(TagError::BadStart { character: '-' }));
107        assert_eq!(Tag::parse("_ev"), Err(TagError::BadStart { character: '_' }));
108    }
109
110    #[test]
111    fn a_tag_long_enough_to_take_the_whole_row_is_refused_with_its_length() {
112        let long = "e".repeat(LONGEST + 1);
113        assert_eq!(Tag::parse(&long), Err(TagError::TooLong { length: LONGEST + 1 }));
114        assert!(Tag::parse(&"e".repeat(LONGEST)).is_ok(), "the longest allowed tag still passes");
115    }
116}