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
6use std::cmp::Ordering;
7use std::fmt;
8use std::str::FromStr;
9
10/// Semantic version number (MAJOR.MINOR.PATCH).
11/// Two-segment form (`MAJOR.MINOR`) is also accepted; patch defaults to 0.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[non_exhaustive]
14pub struct Version {
15    pub major: u32,
16    pub minor: u32,
17    pub patch: u32,
18}
19
20impl Version {
21    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
22        Self {
23            major,
24            minor,
25            patch,
26        }
27    }
28}
29
30impl FromStr for Version {
31    type Err = String;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        let parts: Vec<&str> = s.split('.').collect();
35
36        if !matches!(parts.len(), 2 | 3) {
37            return Err(format!(
38                "Invalid version format '{}': expected MAJOR.MINOR.PATCH or MAJOR.MINOR (e.g., '2.1.0' or '2.1')",
39                s
40            ));
41        }
42
43        let major = parts[0]
44            .parse::<u32>()
45            .map_err(|_| format!("Invalid major version '{}': must be a number", parts[0]))?;
46
47        let minor = parts[1]
48            .parse::<u32>()
49            .map_err(|_| format!("Invalid minor version '{}': must be a number", parts[1]))?;
50
51        let patch = if parts.len() == 3 {
52            parts[2]
53                .parse::<u32>()
54                .map_err(|_| format!("Invalid patch version '{}': must be a number", parts[2]))?
55        } else {
56            0
57        };
58
59        Ok(Version {
60            major,
61            minor,
62            patch,
63        })
64    }
65}
66
67impl fmt::Display for Version {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
70    }
71}
72
73impl PartialOrd for Version {
74    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
75        Some(self.cmp(other))
76    }
77}
78
79impl Ord for Version {
80    fn cmp(&self, other: &Self) -> Ordering {
81        match self.major.cmp(&other.major) {
82            Ordering::Equal => match self.minor.cmp(&other.minor) {
83                Ordering::Equal => self.patch.cmp(&other.patch),
84                other => other,
85            },
86            other => other,
87        }
88    }
89}
90
91/// Specifies which version of a Quill template to use.
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93#[non_exhaustive]
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)]
194#[non_exhaustive]
195pub struct QuillReference {
196    pub name: String,
197    pub selector: VersionSelector,
198}
199
200impl QuillReference {
201    pub fn new(name: String, selector: VersionSelector) -> Self {
202        Self { name, selector }
203    }
204
205    pub fn latest(name: String) -> Self {
206        Self {
207            name,
208            selector: VersionSelector::Latest,
209        }
210    }
211}
212
213impl FromStr for QuillReference {
214    type Err = String;
215
216    fn from_str(s: &str) -> Result<Self, Self::Err> {
217        let separator_idx = s.find('@');
218
219        let (name_part, version_part_opt) = match separator_idx {
220            Some(idx) => (&s[..idx], Some(&s[idx + 1..])),
221            None => (s, None),
222        };
223
224        if name_part.is_empty() {
225            return Err("Quill name cannot be empty".to_string());
226        }
227
228        let name = name_part.to_string();
229
230        // Same charset as a card kind, leading underscore included: one
231        // predicate, in `document::meta`. (Quill *config* names are stricter:
232        // `config.rs` rejects a leading underscore, so its predicate is not
233        // interchangeable with this one.)
234        if !crate::document::is_valid_kind_name(&name) {
235            return Err(format!(
236                "Invalid Quill name '{}': must match [a-z_][a-z0-9_]*",
237                name
238            ));
239        }
240
241        let selector = if let Some(version_part) = version_part_opt {
242            VersionSelector::from_str(&format!("@{}", version_part))?
243        } else {
244            VersionSelector::Latest
245        };
246
247        Ok(QuillReference { name, selector })
248    }
249}
250
251impl fmt::Display for QuillReference {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        match &self.selector {
254            VersionSelector::Latest => write!(f, "{}", self.name),
255            _ => write!(f, "{}{}", self.name, self.selector),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_version_parsing() {
266        let v = Version::from_str("2.1.0").unwrap();
267        assert_eq!(v.major, 2);
268        assert_eq!(v.minor, 1);
269        assert_eq!(v.patch, 0);
270        assert_eq!(v.to_string(), "2.1.0");
271
272        let v2 = Version::from_str("1.2.3").unwrap();
273        assert_eq!(v2.major, 1);
274        assert_eq!(v2.minor, 2);
275        assert_eq!(v2.patch, 3);
276        assert_eq!(v2.to_string(), "1.2.3");
277    }
278
279    #[test]
280    fn test_version_parsing_two_segment_backward_compat() {
281        let v = Version::from_str("2.1").unwrap();
282        assert_eq!(v.major, 2);
283        assert_eq!(v.minor, 1);
284        assert_eq!(v.patch, 0);
285        assert_eq!(v.to_string(), "2.1.0");
286    }
287
288    #[test]
289    fn test_version_invalid() {
290        assert!(Version::from_str("2").is_err());
291        assert!(Version::from_str("2.1.0.0").is_err());
292        assert!(Version::from_str("abc").is_err());
293        assert!(Version::from_str("2.x").is_err());
294        assert!(Version::from_str("2.1.x").is_err());
295    }
296
297    #[test]
298    fn test_version_ordering() {
299        let v1_0_0 = Version::new(1, 0, 0);
300        let v1_0_1 = Version::new(1, 0, 1);
301        let v1_1_0 = Version::new(1, 1, 0);
302        let v2_0_0 = Version::new(2, 0, 0);
303        let v2_1_0 = Version::new(2, 1, 0);
304
305        assert!(v1_0_0 < v1_0_1);
306        assert!(v1_0_1 < v1_1_0);
307        assert!(v1_1_0 < v2_0_0);
308        assert!(v2_0_0 < v2_1_0);
309        assert_eq!(v1_0_0, v1_0_0);
310    }
311
312    #[test]
313    fn test_version_selector_parsing() {
314        let exact = VersionSelector::from_str("@2.1.0").unwrap();
315        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
316
317        let minor = VersionSelector::from_str("@2.1").unwrap();
318        assert_eq!(minor, VersionSelector::Minor(2, 1));
319
320        let major = VersionSelector::from_str("@2").unwrap();
321        assert_eq!(major, VersionSelector::Major(2));
322
323        let latest1 = VersionSelector::from_str("@latest").unwrap();
324        assert_eq!(latest1, VersionSelector::Latest);
325
326        // Empty string also means Latest
327        let latest2 = VersionSelector::from_str("").unwrap();
328        assert_eq!(latest2, VersionSelector::Latest);
329    }
330
331    #[test]
332    fn test_version_selector_without_at() {
333        let exact = VersionSelector::from_str("2.1.0").unwrap();
334        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
335
336        let minor = VersionSelector::from_str("2.1").unwrap();
337        assert_eq!(minor, VersionSelector::Minor(2, 1));
338
339        let major = VersionSelector::from_str("2").unwrap();
340        assert_eq!(major, VersionSelector::Major(2));
341    }
342
343    #[test]
344    fn test_version_selector_matches() {
345        let v2_1_0 = Version::new(2, 1, 0);
346        let v2_1_3 = Version::new(2, 1, 3);
347        let v2_2_0 = Version::new(2, 2, 0);
348        let v3_0_0 = Version::new(3, 0, 0);
349
350        // Exact: only the identical version satisfies.
351        let exact = VersionSelector::Exact(v2_1_0);
352        assert!(exact.matches(v2_1_0));
353        assert!(!exact.matches(v2_1_3));
354        assert!(!exact.matches(v2_2_0));
355
356        // Minor: any patch within the 2.1 series.
357        let minor = VersionSelector::Minor(2, 1);
358        assert!(minor.matches(v2_1_0));
359        assert!(minor.matches(v2_1_3));
360        assert!(!minor.matches(v2_2_0));
361        assert!(!minor.matches(v3_0_0));
362
363        // Major: any minor/patch within the 2 series.
364        let major = VersionSelector::Major(2);
365        assert!(major.matches(v2_1_0));
366        assert!(major.matches(v2_2_0));
367        assert!(!major.matches(v3_0_0));
368
369        // Latest: matches anything.
370        let latest = VersionSelector::Latest;
371        assert!(latest.matches(v2_1_0));
372        assert!(latest.matches(v3_0_0));
373    }
374
375    #[test]
376    fn test_version_selector_display() {
377        assert_eq!(
378            VersionSelector::Exact(Version::new(2, 1, 0)).to_string(),
379            "@2.1.0"
380        );
381        assert_eq!(VersionSelector::Minor(2, 1).to_string(), "@2.1");
382        assert_eq!(VersionSelector::Major(2).to_string(), "@2");
383        assert_eq!(VersionSelector::Latest.to_string(), "@latest");
384    }
385
386    #[test]
387    fn test_quill_reference_parsing() {
388        let ref1 = QuillReference::from_str("resume_template@2.1.0").unwrap();
389        assert_eq!(ref1.name, "resume_template");
390        assert_eq!(ref1.selector, VersionSelector::Exact(Version::new(2, 1, 0)));
391
392        let ref1b = QuillReference::from_str("resume_template@2.1").unwrap();
393        assert_eq!(ref1b.selector, VersionSelector::Minor(2, 1));
394
395        let ref2 = QuillReference::from_str("resume_template@2").unwrap();
396        assert_eq!(ref2.selector, VersionSelector::Major(2));
397
398        let ref3 = QuillReference::from_str("resume_template@latest").unwrap();
399        assert_eq!(ref3.selector, VersionSelector::Latest);
400
401        // No @ suffix: defaults to Latest
402        let ref4 = QuillReference::from_str("resume_template").unwrap();
403        assert_eq!(ref4.name, "resume_template");
404        assert_eq!(ref4.selector, VersionSelector::Latest);
405    }
406
407    #[test]
408    fn test_quill_reference_invalid_names() {
409        assert!(QuillReference::from_str("Resume@2.1.0").is_err());
410        assert!(QuillReference::from_str("1resume@2.1.0").is_err());
411        assert!(QuillReference::from_str("resume-template@2.1.0").is_err());
412        assert!(QuillReference::from_str("resume.template@2.1.0").is_err());
413        assert!(QuillReference::from_str("resume_template@2.1.0").is_ok());
414        assert!(QuillReference::from_str("_private@2.1.0").is_ok());
415        assert!(QuillReference::from_str("template2@2.1.0").is_ok());
416    }
417
418    #[test]
419    fn test_quill_reference_display() {
420        let ref1 = QuillReference::new(
421            "resume".to_string(),
422            VersionSelector::Exact(Version::new(2, 1, 0)),
423        );
424        assert_eq!(ref1.to_string(), "resume@2.1.0");
425
426        let ref1b = QuillReference::new("resume".to_string(), VersionSelector::Minor(2, 1));
427        assert_eq!(ref1b.to_string(), "resume@2.1");
428
429        let ref2 = QuillReference::new("resume".to_string(), VersionSelector::Major(2));
430        assert_eq!(ref2.to_string(), "resume@2");
431
432        let ref3 = QuillReference::new("resume".to_string(), VersionSelector::Latest);
433        assert_eq!(ref3.to_string(), "resume");
434    }
435
436    #[test]
437    fn test_quill_ref_hint_describes_the_grammar() {
438        let hint = quill_ref_hint();
439        assert!(!hint.is_empty());
440        // Pin the charset and selector forms so the hint can't drift from `from_str`.
441        assert!(hint.contains("[a-z_][a-z0-9_]*"), "got: {hint}");
442        assert!(hint.contains("@latest"), "got: {hint}");
443        assert!(hint.contains("@MAJOR.MINOR.PATCH"), "got: {hint}");
444    }
445}