Skip to main content

made_core/value_objects/ceremony/
ceremony_version.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::DomainError;
6
7const DEFAULT_VERSION: &str = "1.0";
8const MAX_VERSION_LEN: usize = 64;
9
10/// The version of a working session's definition.
11///
12/// This is the ceremony's own version, not the version of the document
13/// it was written in. It was pinned to a single accepted value while
14/// there was nothing to distinguish — with publication there is: a
15/// published version is immutable, so a definition that changes needs
16/// somewhere to change *to*.
17///
18/// No ordering is imposed. The engine needs to tell two versions apart,
19/// not to decide which is newer; a scheme that ranked them would be
20/// making a release-management decision on behalf of every consumer.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct CeremonyVersion(String);
24
25impl CeremonyVersion {
26    pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
27        let value = raw.into();
28        let trimmed = value.trim();
29        if trimmed.is_empty() {
30            return Err(DomainError::EmptyField {
31                field: "ceremony_version",
32            });
33        }
34        if trimmed.len() > MAX_VERSION_LEN {
35            return Err(DomainError::FieldTooLong {
36                field: "ceremony_version",
37                actual: trimmed.len(),
38                max: MAX_VERSION_LEN,
39            });
40        }
41        // A version is part of a published identity, so it appears in
42        // storage keys and in digests. The charset is narrow enough
43        // that it never needs escaping wherever it is carried.
44        if !trimmed
45            .chars()
46            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
47        {
48            return Err(DomainError::InvalidCharacters {
49                field: "ceremony_version",
50            });
51        }
52        Ok(Self(trimmed.to_owned()))
53    }
54
55    /// The version a definition carries when its author did not choose
56    /// one.
57    #[must_use]
58    pub fn v1() -> Self {
59        Self(DEFAULT_VERSION.to_owned())
60    }
61
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl fmt::Display for CeremonyVersion {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(&self.0)
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn a_second_version_is_now_expressible() {
80        assert_eq!(CeremonyVersion::new("2.0").unwrap().as_str(), "2.0");
81        assert_eq!(CeremonyVersion::new("v2-rc1").unwrap().as_str(), "v2-rc1");
82        assert_eq!(
83            CeremonyVersion::new("2026-07-30").unwrap().as_str(),
84            "2026-07-30"
85        );
86    }
87
88    #[test]
89    fn the_default_is_what_existing_documents_carry() {
90        assert_eq!(CeremonyVersion::v1().as_str(), "1.0");
91        assert_eq!(CeremonyVersion::new("1.0").unwrap(), CeremonyVersion::v1());
92    }
93
94    #[test]
95    fn characters_that_would_need_escaping_in_a_key_are_rejected() {
96        for rejected in [
97            "",
98            "  ",
99            "1.0/2",
100            "1 0",
101            "1.0\u{0}",
102            "a".repeat(65).as_str(),
103        ] {
104            assert!(
105                CeremonyVersion::new(rejected).is_err(),
106                "{rejected:?} was accepted"
107            );
108        }
109    }
110}