Skip to main content

quillmark_core/
version.rs

1//! # Version Management
2//!
3//! Semantic versioning (MAJOR.MINOR.PATCH) for Quill template references.
4//! Two-segment (`MAJOR.MINOR`) versions are also accepted; patch defaults to 0.
5//!
6//! Key types: [`Version`], [`VersionSelector`], [`QuillReference`].
7
8use std::cmp::Ordering;
9use std::fmt;
10use std::str::FromStr;
11
12/// Semantic version number (MAJOR.MINOR.PATCH).
13/// Two-segment form (`MAJOR.MINOR`) is also accepted; patch defaults to 0.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct Version {
16    pub major: u32,
17    pub minor: u32,
18    pub patch: u32,
19}
20
21impl Version {
22    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
23        Self {
24            major,
25            minor,
26            patch,
27        }
28    }
29}
30
31impl FromStr for Version {
32    type Err = String;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        let parts: Vec<&str> = s.split('.').collect();
36
37        if !matches!(parts.len(), 2 | 3) {
38            return Err(format!(
39                "Invalid version format '{}': expected MAJOR.MINOR.PATCH or MAJOR.MINOR (e.g., '2.1.0' or '2.1')",
40                s
41            ));
42        }
43
44        let major = parts[0]
45            .parse::<u32>()
46            .map_err(|_| format!("Invalid major version '{}': must be a number", parts[0]))?;
47
48        let minor = parts[1]
49            .parse::<u32>()
50            .map_err(|_| format!("Invalid minor version '{}': must be a number", parts[1]))?;
51
52        let patch = if parts.len() == 3 {
53            parts[2]
54                .parse::<u32>()
55                .map_err(|_| format!("Invalid patch version '{}': must be a number", parts[2]))?
56        } else {
57            0
58        };
59
60        Ok(Version {
61            major,
62            minor,
63            patch,
64        })
65    }
66}
67
68impl fmt::Display for Version {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
71    }
72}
73
74impl PartialOrd for Version {
75    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
76        Some(self.cmp(other))
77    }
78}
79
80impl Ord for Version {
81    fn cmp(&self, other: &Self) -> Ordering {
82        match self.major.cmp(&other.major) {
83            Ordering::Equal => match self.minor.cmp(&other.minor) {
84                Ordering::Equal => self.patch.cmp(&other.patch),
85                other => other,
86            },
87            other => other,
88        }
89    }
90}
91
92/// Specifies which version of a Quill template to use.
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub enum VersionSelector {
95    /// Match exactly this version (e.g., "@2.1.0")
96    Exact(Version),
97    /// Match latest patch version in this minor series (e.g., "@2.1")
98    Minor(u32, u32),
99    /// Match latest minor/patch version in this major series (e.g., "@2")
100    Major(u32),
101    /// Match the highest version available (e.g., "@latest" or unspecified)
102    Latest,
103}
104
105impl FromStr for VersionSelector {
106    type Err = String;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        let version_str = s.strip_prefix('@').unwrap_or(s);
110
111        if version_str.is_empty() || version_str == "latest" {
112            return Ok(VersionSelector::Latest);
113        }
114
115        let parts: Vec<&str> = version_str.split('.').collect();
116
117        match parts.len() {
118            3 => {
119                let version = Version::from_str(version_str)?;
120                Ok(VersionSelector::Exact(version))
121            }
122            2 => {
123                let major = parts[0].parse::<u32>().map_err(|_| {
124                    format!("Invalid major version '{}': must be a number", parts[0])
125                })?;
126                let minor = parts[1].parse::<u32>().map_err(|_| {
127                    format!("Invalid minor version '{}': must be a number", parts[1])
128                })?;
129                Ok(VersionSelector::Minor(major, minor))
130            }
131            1 => {
132                let major = version_str.parse::<u32>().map_err(|_| {
133                    format!(
134                        "Invalid version selector '{}': expected number, MAJOR.MINOR, MAJOR.MINOR.PATCH, or 'latest'",
135                        version_str
136                    )
137                })?;
138                Ok(VersionSelector::Major(major))
139            }
140            _ => Err(format!(
141                "Invalid version selector '{}': expected number, MAJOR.MINOR, MAJOR.MINOR.PATCH, or 'latest'",
142                version_str
143            )),
144        }
145    }
146}
147
148impl fmt::Display for VersionSelector {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            VersionSelector::Exact(v) => write!(f, "@{}", v),
152            VersionSelector::Minor(major, minor) => write!(f, "@{}.{}", major, minor),
153            VersionSelector::Major(m) => write!(f, "@{}", m),
154            VersionSelector::Latest => write!(f, "@latest"),
155        }
156    }
157}
158
159/// Complete reference to a Quill template with name and version selector.
160///
161/// Name charset: `[a-z_][a-z0-9_]*`. Selector defaults to `Latest` when omitted.
162#[derive(Debug, Clone, PartialEq, Eq, Hash)]
163pub struct QuillReference {
164    pub name: String,
165    pub selector: VersionSelector,
166}
167
168impl QuillReference {
169    pub fn new(name: String, selector: VersionSelector) -> Self {
170        Self { name, selector }
171    }
172
173    pub fn latest(name: String) -> Self {
174        Self {
175            name,
176            selector: VersionSelector::Latest,
177        }
178    }
179}
180
181impl FromStr for QuillReference {
182    type Err = String;
183
184    fn from_str(s: &str) -> Result<Self, Self::Err> {
185        let separator_idx = s.find('@');
186
187        let (name_part, version_part_opt) = match separator_idx {
188            Some(idx) => (&s[..idx], Some(&s[idx + 1..])),
189            None => (s, None),
190        };
191
192        if name_part.is_empty() {
193            return Err("Quill name cannot be empty".to_string());
194        }
195
196        let name = name_part.to_string();
197
198        if !name
199            .chars()
200            .next()
201            .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
202        {
203            return Err(format!(
204                "Invalid Quill name '{}': must start with lowercase letter or underscore",
205                name
206            ));
207        }
208        if !name
209            .chars()
210            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
211        {
212            return Err(format!(
213                "Invalid Quill name '{}': must contain only lowercase letters, digits, and underscores",
214                name
215            ));
216        }
217
218        let selector = if let Some(version_part) = version_part_opt {
219            VersionSelector::from_str(&format!("@{}", version_part))?
220        } else {
221            VersionSelector::Latest
222        };
223
224        Ok(QuillReference { name, selector })
225    }
226}
227
228impl fmt::Display for QuillReference {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match &self.selector {
231            VersionSelector::Latest => write!(f, "{}", self.name),
232            _ => write!(f, "{}{}", self.name, self.selector),
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_version_parsing() {
243        let v = Version::from_str("2.1.0").unwrap();
244        assert_eq!(v.major, 2);
245        assert_eq!(v.minor, 1);
246        assert_eq!(v.patch, 0);
247        assert_eq!(v.to_string(), "2.1.0");
248
249        let v2 = Version::from_str("1.2.3").unwrap();
250        assert_eq!(v2.major, 1);
251        assert_eq!(v2.minor, 2);
252        assert_eq!(v2.patch, 3);
253        assert_eq!(v2.to_string(), "1.2.3");
254    }
255
256    #[test]
257    fn test_version_parsing_two_segment_backward_compat() {
258        let v = Version::from_str("2.1").unwrap();
259        assert_eq!(v.major, 2);
260        assert_eq!(v.minor, 1);
261        assert_eq!(v.patch, 0);
262        assert_eq!(v.to_string(), "2.1.0");
263    }
264
265    #[test]
266    fn test_version_invalid() {
267        assert!(Version::from_str("2").is_err());
268        assert!(Version::from_str("2.1.0.0").is_err());
269        assert!(Version::from_str("abc").is_err());
270        assert!(Version::from_str("2.x").is_err());
271        assert!(Version::from_str("2.1.x").is_err());
272    }
273
274    #[test]
275    fn test_version_ordering() {
276        let v1_0_0 = Version::new(1, 0, 0);
277        let v1_0_1 = Version::new(1, 0, 1);
278        let v1_1_0 = Version::new(1, 1, 0);
279        let v2_0_0 = Version::new(2, 0, 0);
280        let v2_1_0 = Version::new(2, 1, 0);
281
282        assert!(v1_0_0 < v1_0_1);
283        assert!(v1_0_1 < v1_1_0);
284        assert!(v1_1_0 < v2_0_0);
285        assert!(v2_0_0 < v2_1_0);
286        assert_eq!(v1_0_0, v1_0_0);
287    }
288
289    #[test]
290    fn test_version_selector_parsing() {
291        let exact = VersionSelector::from_str("@2.1.0").unwrap();
292        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
293
294        let minor = VersionSelector::from_str("@2.1").unwrap();
295        assert_eq!(minor, VersionSelector::Minor(2, 1));
296
297        let major = VersionSelector::from_str("@2").unwrap();
298        assert_eq!(major, VersionSelector::Major(2));
299
300        let latest1 = VersionSelector::from_str("@latest").unwrap();
301        assert_eq!(latest1, VersionSelector::Latest);
302
303        // Empty string also means Latest
304        let latest2 = VersionSelector::from_str("").unwrap();
305        assert_eq!(latest2, VersionSelector::Latest);
306    }
307
308    #[test]
309    fn test_version_selector_without_at() {
310        let exact = VersionSelector::from_str("2.1.0").unwrap();
311        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
312
313        let minor = VersionSelector::from_str("2.1").unwrap();
314        assert_eq!(minor, VersionSelector::Minor(2, 1));
315
316        let major = VersionSelector::from_str("2").unwrap();
317        assert_eq!(major, VersionSelector::Major(2));
318    }
319
320    #[test]
321    fn test_version_selector_display() {
322        assert_eq!(
323            VersionSelector::Exact(Version::new(2, 1, 0)).to_string(),
324            "@2.1.0"
325        );
326        assert_eq!(VersionSelector::Minor(2, 1).to_string(), "@2.1");
327        assert_eq!(VersionSelector::Major(2).to_string(), "@2");
328        assert_eq!(VersionSelector::Latest.to_string(), "@latest");
329    }
330
331    #[test]
332    fn test_quill_reference_parsing() {
333        let ref1 = QuillReference::from_str("resume_template@2.1.0").unwrap();
334        assert_eq!(ref1.name, "resume_template");
335        assert_eq!(ref1.selector, VersionSelector::Exact(Version::new(2, 1, 0)));
336
337        let ref1b = QuillReference::from_str("resume_template@2.1").unwrap();
338        assert_eq!(ref1b.selector, VersionSelector::Minor(2, 1));
339
340        let ref2 = QuillReference::from_str("resume_template@2").unwrap();
341        assert_eq!(ref2.selector, VersionSelector::Major(2));
342
343        let ref3 = QuillReference::from_str("resume_template@latest").unwrap();
344        assert_eq!(ref3.selector, VersionSelector::Latest);
345
346        // No @ suffix — defaults to Latest
347        let ref4 = QuillReference::from_str("resume_template").unwrap();
348        assert_eq!(ref4.name, "resume_template");
349        assert_eq!(ref4.selector, VersionSelector::Latest);
350    }
351
352    #[test]
353    fn test_quill_reference_invalid_names() {
354        assert!(QuillReference::from_str("Resume@2.1.0").is_err());
355        assert!(QuillReference::from_str("1resume@2.1.0").is_err());
356        assert!(QuillReference::from_str("resume-template@2.1.0").is_err());
357        assert!(QuillReference::from_str("resume.template@2.1.0").is_err());
358        assert!(QuillReference::from_str("resume_template@2.1.0").is_ok());
359        assert!(QuillReference::from_str("_private@2.1.0").is_ok());
360        assert!(QuillReference::from_str("template2@2.1.0").is_ok());
361    }
362
363    #[test]
364    fn test_quill_reference_display() {
365        let ref1 = QuillReference::new(
366            "resume".to_string(),
367            VersionSelector::Exact(Version::new(2, 1, 0)),
368        );
369        assert_eq!(ref1.to_string(), "resume@2.1.0");
370
371        let ref1b = QuillReference::new("resume".to_string(), VersionSelector::Minor(2, 1));
372        assert_eq!(ref1b.to_string(), "resume@2.1");
373
374        let ref2 = QuillReference::new("resume".to_string(), VersionSelector::Major(2));
375        assert_eq!(ref2.to_string(), "resume@2");
376
377        let ref3 = QuillReference::new("resume".to_string(), VersionSelector::Latest);
378        assert_eq!(ref3.to_string(), "resume");
379    }
380}