Skip to main content

quillmark_core/
version.rs

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