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 VersionSelector {
106    /// Whether `v` satisfies this selector: `Exact` the identical version,
107    /// `Minor` any patch in the `MAJOR.MINOR` series, `Major` any version in the
108    /// `MAJOR` series, `Latest` anything. A compatibility check, not resolution
109    /// — a `false` is the `quill::version_mismatch` render error.
110    pub fn matches(&self, v: Version) -> bool {
111        match self {
112            VersionSelector::Exact(want) => *want == v,
113            VersionSelector::Minor(major, minor) => v.major == *major && v.minor == *minor,
114            VersionSelector::Major(major) => v.major == *major,
115            VersionSelector::Latest => true,
116        }
117    }
118}
119
120impl FromStr for VersionSelector {
121    type Err = String;
122
123    fn from_str(s: &str) -> Result<Self, Self::Err> {
124        let version_str = s.strip_prefix('@').unwrap_or(s);
125
126        if version_str.is_empty() || version_str == "latest" {
127            return Ok(VersionSelector::Latest);
128        }
129
130        let parts: Vec<&str> = version_str.split('.').collect();
131
132        match parts.len() {
133            3 => {
134                let version = Version::from_str(version_str)?;
135                Ok(VersionSelector::Exact(version))
136            }
137            2 => {
138                let major = parts[0].parse::<u32>().map_err(|_| {
139                    format!("Invalid major version '{}': must be a number", parts[0])
140                })?;
141                let minor = parts[1].parse::<u32>().map_err(|_| {
142                    format!("Invalid minor version '{}': must be a number", parts[1])
143                })?;
144                Ok(VersionSelector::Minor(major, minor))
145            }
146            1 => {
147                let major = version_str.parse::<u32>().map_err(|_| {
148                    format!(
149                        "Invalid version selector '{}': expected number, MAJOR.MINOR, MAJOR.MINOR.PATCH, or 'latest'",
150                        version_str
151                    )
152                })?;
153                Ok(VersionSelector::Major(major))
154            }
155            _ => Err(format!(
156                "Invalid version selector '{}': expected number, MAJOR.MINOR, MAJOR.MINOR.PATCH, or 'latest'",
157                version_str
158            )),
159        }
160    }
161}
162
163impl fmt::Display for VersionSelector {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            VersionSelector::Exact(v) => write!(f, "@{}", v),
167            VersionSelector::Minor(major, minor) => write!(f, "@{}.{}", major, minor),
168            VersionSelector::Major(m) => write!(f, "@{}", m),
169            VersionSelector::Latest => write!(f, "@latest"),
170        }
171    }
172}
173
174/// Canonical, author-facing `$quill` reference grammar.
175const QUILL_REF_HINT: &str = "A $quill reference is `<name>` or `<name>@<selector>`. \
176The name must match `[a-z_][a-z0-9_]*` (start with a lowercase letter or underscore, then \
177lowercase letters, digits, or underscores). The optional version selector is \
178`@MAJOR.MINOR.PATCH` (exact), `@MAJOR.MINOR` (latest patch in that minor series), `@MAJOR` \
179(latest in that major series), or `@latest`; omitting the selector means latest.";
180
181/// Single source of truth for the grammar [`QuillReference::from_str`] enforces:
182/// bindings surface it (schema `describe`, validation hints) and it rides as the
183/// `hint` on the `parse::invalid_quill_reference` diagnostic, so error and
184/// describe text can't drift from the parser. Sibling of `document`'s
185/// `FORMAT_RULES` / `blueprint_instruction`.
186pub fn quill_ref_hint() -> &'static str {
187    QUILL_REF_HINT
188}
189
190/// Complete reference to a Quill template with name and version selector.
191///
192/// Name charset: `[a-z_][a-z0-9_]*`. Selector defaults to `Latest` when omitted.
193#[derive(Debug, Clone, PartialEq, Eq, Hash)]
194pub struct QuillReference {
195    pub name: String,
196    pub selector: VersionSelector,
197}
198
199impl QuillReference {
200    pub fn new(name: String, selector: VersionSelector) -> Self {
201        Self { name, selector }
202    }
203
204    pub fn latest(name: String) -> Self {
205        Self {
206            name,
207            selector: VersionSelector::Latest,
208        }
209    }
210}
211
212impl FromStr for QuillReference {
213    type Err = String;
214
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        let separator_idx = s.find('@');
217
218        let (name_part, version_part_opt) = match separator_idx {
219            Some(idx) => (&s[..idx], Some(&s[idx + 1..])),
220            None => (s, None),
221        };
222
223        if name_part.is_empty() {
224            return Err("Quill name cannot be empty".to_string());
225        }
226
227        let name = name_part.to_string();
228
229        if !name
230            .chars()
231            .next()
232            .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
233        {
234            return Err(format!(
235                "Invalid Quill name '{}': must start with lowercase letter or underscore",
236                name
237            ));
238        }
239        if !name
240            .chars()
241            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
242        {
243            return Err(format!(
244                "Invalid Quill name '{}': must contain only lowercase letters, digits, and underscores",
245                name
246            ));
247        }
248
249        let selector = if let Some(version_part) = version_part_opt {
250            VersionSelector::from_str(&format!("@{}", version_part))?
251        } else {
252            VersionSelector::Latest
253        };
254
255        Ok(QuillReference { name, selector })
256    }
257}
258
259impl fmt::Display for QuillReference {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        match &self.selector {
262            VersionSelector::Latest => write!(f, "{}", self.name),
263            _ => write!(f, "{}{}", self.name, self.selector),
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_version_parsing() {
274        let v = Version::from_str("2.1.0").unwrap();
275        assert_eq!(v.major, 2);
276        assert_eq!(v.minor, 1);
277        assert_eq!(v.patch, 0);
278        assert_eq!(v.to_string(), "2.1.0");
279
280        let v2 = Version::from_str("1.2.3").unwrap();
281        assert_eq!(v2.major, 1);
282        assert_eq!(v2.minor, 2);
283        assert_eq!(v2.patch, 3);
284        assert_eq!(v2.to_string(), "1.2.3");
285    }
286
287    #[test]
288    fn test_version_parsing_two_segment_backward_compat() {
289        let v = Version::from_str("2.1").unwrap();
290        assert_eq!(v.major, 2);
291        assert_eq!(v.minor, 1);
292        assert_eq!(v.patch, 0);
293        assert_eq!(v.to_string(), "2.1.0");
294    }
295
296    #[test]
297    fn test_version_invalid() {
298        assert!(Version::from_str("2").is_err());
299        assert!(Version::from_str("2.1.0.0").is_err());
300        assert!(Version::from_str("abc").is_err());
301        assert!(Version::from_str("2.x").is_err());
302        assert!(Version::from_str("2.1.x").is_err());
303    }
304
305    #[test]
306    fn test_version_ordering() {
307        let v1_0_0 = Version::new(1, 0, 0);
308        let v1_0_1 = Version::new(1, 0, 1);
309        let v1_1_0 = Version::new(1, 1, 0);
310        let v2_0_0 = Version::new(2, 0, 0);
311        let v2_1_0 = Version::new(2, 1, 0);
312
313        assert!(v1_0_0 < v1_0_1);
314        assert!(v1_0_1 < v1_1_0);
315        assert!(v1_1_0 < v2_0_0);
316        assert!(v2_0_0 < v2_1_0);
317        assert_eq!(v1_0_0, v1_0_0);
318    }
319
320    #[test]
321    fn test_version_selector_parsing() {
322        let exact = VersionSelector::from_str("@2.1.0").unwrap();
323        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
324
325        let minor = VersionSelector::from_str("@2.1").unwrap();
326        assert_eq!(minor, VersionSelector::Minor(2, 1));
327
328        let major = VersionSelector::from_str("@2").unwrap();
329        assert_eq!(major, VersionSelector::Major(2));
330
331        let latest1 = VersionSelector::from_str("@latest").unwrap();
332        assert_eq!(latest1, VersionSelector::Latest);
333
334        // Empty string also means Latest
335        let latest2 = VersionSelector::from_str("").unwrap();
336        assert_eq!(latest2, VersionSelector::Latest);
337    }
338
339    #[test]
340    fn test_version_selector_without_at() {
341        let exact = VersionSelector::from_str("2.1.0").unwrap();
342        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
343
344        let minor = VersionSelector::from_str("2.1").unwrap();
345        assert_eq!(minor, VersionSelector::Minor(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_version_selector_matches() {
353        let v2_1_0 = Version::new(2, 1, 0);
354        let v2_1_3 = Version::new(2, 1, 3);
355        let v2_2_0 = Version::new(2, 2, 0);
356        let v3_0_0 = Version::new(3, 0, 0);
357
358        // Exact: only the identical version satisfies.
359        let exact = VersionSelector::Exact(v2_1_0);
360        assert!(exact.matches(v2_1_0));
361        assert!(!exact.matches(v2_1_3));
362        assert!(!exact.matches(v2_2_0));
363
364        // Minor: any patch within the 2.1 series.
365        let minor = VersionSelector::Minor(2, 1);
366        assert!(minor.matches(v2_1_0));
367        assert!(minor.matches(v2_1_3));
368        assert!(!minor.matches(v2_2_0));
369        assert!(!minor.matches(v3_0_0));
370
371        // Major: any minor/patch within the 2 series.
372        let major = VersionSelector::Major(2);
373        assert!(major.matches(v2_1_0));
374        assert!(major.matches(v2_2_0));
375        assert!(!major.matches(v3_0_0));
376
377        // Latest: matches anything.
378        let latest = VersionSelector::Latest;
379        assert!(latest.matches(v2_1_0));
380        assert!(latest.matches(v3_0_0));
381    }
382
383    #[test]
384    fn test_version_selector_display() {
385        assert_eq!(
386            VersionSelector::Exact(Version::new(2, 1, 0)).to_string(),
387            "@2.1.0"
388        );
389        assert_eq!(VersionSelector::Minor(2, 1).to_string(), "@2.1");
390        assert_eq!(VersionSelector::Major(2).to_string(), "@2");
391        assert_eq!(VersionSelector::Latest.to_string(), "@latest");
392    }
393
394    #[test]
395    fn test_quill_reference_parsing() {
396        let ref1 = QuillReference::from_str("resume_template@2.1.0").unwrap();
397        assert_eq!(ref1.name, "resume_template");
398        assert_eq!(ref1.selector, VersionSelector::Exact(Version::new(2, 1, 0)));
399
400        let ref1b = QuillReference::from_str("resume_template@2.1").unwrap();
401        assert_eq!(ref1b.selector, VersionSelector::Minor(2, 1));
402
403        let ref2 = QuillReference::from_str("resume_template@2").unwrap();
404        assert_eq!(ref2.selector, VersionSelector::Major(2));
405
406        let ref3 = QuillReference::from_str("resume_template@latest").unwrap();
407        assert_eq!(ref3.selector, VersionSelector::Latest);
408
409        // No @ suffix — defaults to Latest
410        let ref4 = QuillReference::from_str("resume_template").unwrap();
411        assert_eq!(ref4.name, "resume_template");
412        assert_eq!(ref4.selector, VersionSelector::Latest);
413    }
414
415    #[test]
416    fn test_quill_reference_invalid_names() {
417        assert!(QuillReference::from_str("Resume@2.1.0").is_err());
418        assert!(QuillReference::from_str("1resume@2.1.0").is_err());
419        assert!(QuillReference::from_str("resume-template@2.1.0").is_err());
420        assert!(QuillReference::from_str("resume.template@2.1.0").is_err());
421        assert!(QuillReference::from_str("resume_template@2.1.0").is_ok());
422        assert!(QuillReference::from_str("_private@2.1.0").is_ok());
423        assert!(QuillReference::from_str("template2@2.1.0").is_ok());
424    }
425
426    #[test]
427    fn test_quill_reference_display() {
428        let ref1 = QuillReference::new(
429            "resume".to_string(),
430            VersionSelector::Exact(Version::new(2, 1, 0)),
431        );
432        assert_eq!(ref1.to_string(), "resume@2.1.0");
433
434        let ref1b = QuillReference::new("resume".to_string(), VersionSelector::Minor(2, 1));
435        assert_eq!(ref1b.to_string(), "resume@2.1");
436
437        let ref2 = QuillReference::new("resume".to_string(), VersionSelector::Major(2));
438        assert_eq!(ref2.to_string(), "resume@2");
439
440        let ref3 = QuillReference::new("resume".to_string(), VersionSelector::Latest);
441        assert_eq!(ref3.to_string(), "resume");
442    }
443
444    #[test]
445    fn test_quill_ref_hint_describes_the_grammar() {
446        let hint = quill_ref_hint();
447        assert!(!hint.is_empty());
448        // Pin the charset and selector forms so the hint can't drift from `from_str`.
449        assert!(hint.contains("[a-z_][a-z0-9_]*"), "got: {hint}");
450        assert!(hint.contains("@latest"), "got: {hint}");
451        assert!(hint.contains("@MAJOR.MINOR.PATCH"), "got: {hint}");
452    }
453}