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        // Same charset as a card kind, leading underscore included — one
230        // predicate, in `document::meta`. (Quill *config* names are stricter:
231        // `config.rs` rejects a leading underscore, so its predicate is not
232        // interchangeable with this one.)
233        if !crate::document::is_valid_kind_name(&name) {
234            return Err(format!(
235                "Invalid Quill name '{}': must match [a-z_][a-z0-9_]*",
236                name
237            ));
238        }
239
240        let selector = if let Some(version_part) = version_part_opt {
241            VersionSelector::from_str(&format!("@{}", version_part))?
242        } else {
243            VersionSelector::Latest
244        };
245
246        Ok(QuillReference { name, selector })
247    }
248}
249
250impl fmt::Display for QuillReference {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        match &self.selector {
253            VersionSelector::Latest => write!(f, "{}", self.name),
254            _ => write!(f, "{}{}", self.name, self.selector),
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_version_parsing() {
265        let v = Version::from_str("2.1.0").unwrap();
266        assert_eq!(v.major, 2);
267        assert_eq!(v.minor, 1);
268        assert_eq!(v.patch, 0);
269        assert_eq!(v.to_string(), "2.1.0");
270
271        let v2 = Version::from_str("1.2.3").unwrap();
272        assert_eq!(v2.major, 1);
273        assert_eq!(v2.minor, 2);
274        assert_eq!(v2.patch, 3);
275        assert_eq!(v2.to_string(), "1.2.3");
276    }
277
278    #[test]
279    fn test_version_parsing_two_segment_backward_compat() {
280        let v = Version::from_str("2.1").unwrap();
281        assert_eq!(v.major, 2);
282        assert_eq!(v.minor, 1);
283        assert_eq!(v.patch, 0);
284        assert_eq!(v.to_string(), "2.1.0");
285    }
286
287    #[test]
288    fn test_version_invalid() {
289        assert!(Version::from_str("2").is_err());
290        assert!(Version::from_str("2.1.0.0").is_err());
291        assert!(Version::from_str("abc").is_err());
292        assert!(Version::from_str("2.x").is_err());
293        assert!(Version::from_str("2.1.x").is_err());
294    }
295
296    #[test]
297    fn test_version_ordering() {
298        let v1_0_0 = Version::new(1, 0, 0);
299        let v1_0_1 = Version::new(1, 0, 1);
300        let v1_1_0 = Version::new(1, 1, 0);
301        let v2_0_0 = Version::new(2, 0, 0);
302        let v2_1_0 = Version::new(2, 1, 0);
303
304        assert!(v1_0_0 < v1_0_1);
305        assert!(v1_0_1 < v1_1_0);
306        assert!(v1_1_0 < v2_0_0);
307        assert!(v2_0_0 < v2_1_0);
308        assert_eq!(v1_0_0, v1_0_0);
309    }
310
311    #[test]
312    fn test_version_selector_parsing() {
313        let exact = VersionSelector::from_str("@2.1.0").unwrap();
314        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
315
316        let minor = VersionSelector::from_str("@2.1").unwrap();
317        assert_eq!(minor, VersionSelector::Minor(2, 1));
318
319        let major = VersionSelector::from_str("@2").unwrap();
320        assert_eq!(major, VersionSelector::Major(2));
321
322        let latest1 = VersionSelector::from_str("@latest").unwrap();
323        assert_eq!(latest1, VersionSelector::Latest);
324
325        // Empty string also means Latest
326        let latest2 = VersionSelector::from_str("").unwrap();
327        assert_eq!(latest2, VersionSelector::Latest);
328    }
329
330    #[test]
331    fn test_version_selector_without_at() {
332        let exact = VersionSelector::from_str("2.1.0").unwrap();
333        assert_eq!(exact, VersionSelector::Exact(Version::new(2, 1, 0)));
334
335        let minor = VersionSelector::from_str("2.1").unwrap();
336        assert_eq!(minor, VersionSelector::Minor(2, 1));
337
338        let major = VersionSelector::from_str("2").unwrap();
339        assert_eq!(major, VersionSelector::Major(2));
340    }
341
342    #[test]
343    fn test_version_selector_matches() {
344        let v2_1_0 = Version::new(2, 1, 0);
345        let v2_1_3 = Version::new(2, 1, 3);
346        let v2_2_0 = Version::new(2, 2, 0);
347        let v3_0_0 = Version::new(3, 0, 0);
348
349        // Exact: only the identical version satisfies.
350        let exact = VersionSelector::Exact(v2_1_0);
351        assert!(exact.matches(v2_1_0));
352        assert!(!exact.matches(v2_1_3));
353        assert!(!exact.matches(v2_2_0));
354
355        // Minor: any patch within the 2.1 series.
356        let minor = VersionSelector::Minor(2, 1);
357        assert!(minor.matches(v2_1_0));
358        assert!(minor.matches(v2_1_3));
359        assert!(!minor.matches(v2_2_0));
360        assert!(!minor.matches(v3_0_0));
361
362        // Major: any minor/patch within the 2 series.
363        let major = VersionSelector::Major(2);
364        assert!(major.matches(v2_1_0));
365        assert!(major.matches(v2_2_0));
366        assert!(!major.matches(v3_0_0));
367
368        // Latest: matches anything.
369        let latest = VersionSelector::Latest;
370        assert!(latest.matches(v2_1_0));
371        assert!(latest.matches(v3_0_0));
372    }
373
374    #[test]
375    fn test_version_selector_display() {
376        assert_eq!(
377            VersionSelector::Exact(Version::new(2, 1, 0)).to_string(),
378            "@2.1.0"
379        );
380        assert_eq!(VersionSelector::Minor(2, 1).to_string(), "@2.1");
381        assert_eq!(VersionSelector::Major(2).to_string(), "@2");
382        assert_eq!(VersionSelector::Latest.to_string(), "@latest");
383    }
384
385    #[test]
386    fn test_quill_reference_parsing() {
387        let ref1 = QuillReference::from_str("resume_template@2.1.0").unwrap();
388        assert_eq!(ref1.name, "resume_template");
389        assert_eq!(ref1.selector, VersionSelector::Exact(Version::new(2, 1, 0)));
390
391        let ref1b = QuillReference::from_str("resume_template@2.1").unwrap();
392        assert_eq!(ref1b.selector, VersionSelector::Minor(2, 1));
393
394        let ref2 = QuillReference::from_str("resume_template@2").unwrap();
395        assert_eq!(ref2.selector, VersionSelector::Major(2));
396
397        let ref3 = QuillReference::from_str("resume_template@latest").unwrap();
398        assert_eq!(ref3.selector, VersionSelector::Latest);
399
400        // No @ suffix — defaults to Latest
401        let ref4 = QuillReference::from_str("resume_template").unwrap();
402        assert_eq!(ref4.name, "resume_template");
403        assert_eq!(ref4.selector, VersionSelector::Latest);
404    }
405
406    #[test]
407    fn test_quill_reference_invalid_names() {
408        assert!(QuillReference::from_str("Resume@2.1.0").is_err());
409        assert!(QuillReference::from_str("1resume@2.1.0").is_err());
410        assert!(QuillReference::from_str("resume-template@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_ok());
413        assert!(QuillReference::from_str("_private@2.1.0").is_ok());
414        assert!(QuillReference::from_str("template2@2.1.0").is_ok());
415    }
416
417    #[test]
418    fn test_quill_reference_display() {
419        let ref1 = QuillReference::new(
420            "resume".to_string(),
421            VersionSelector::Exact(Version::new(2, 1, 0)),
422        );
423        assert_eq!(ref1.to_string(), "resume@2.1.0");
424
425        let ref1b = QuillReference::new("resume".to_string(), VersionSelector::Minor(2, 1));
426        assert_eq!(ref1b.to_string(), "resume@2.1");
427
428        let ref2 = QuillReference::new("resume".to_string(), VersionSelector::Major(2));
429        assert_eq!(ref2.to_string(), "resume@2");
430
431        let ref3 = QuillReference::new("resume".to_string(), VersionSelector::Latest);
432        assert_eq!(ref3.to_string(), "resume");
433    }
434
435    #[test]
436    fn test_quill_ref_hint_describes_the_grammar() {
437        let hint = quill_ref_hint();
438        assert!(!hint.is_empty());
439        // Pin the charset and selector forms so the hint can't drift from `from_str`.
440        assert!(hint.contains("[a-z_][a-z0-9_]*"), "got: {hint}");
441        assert!(hint.contains("@latest"), "got: {hint}");
442        assert!(hint.contains("@MAJOR.MINOR.PATCH"), "got: {hint}");
443    }
444}