Skip to main content

ephemeris_release/
commit_subject.rs

1//! The subject template of proposal commits.
2
3use std::fmt::Display;
4use std::iter;
5
6use facet::Facet;
7
8const UNIT: &str = "{unit}";
9const VERSION: &str = "{version}";
10
11/// A subject template with the placeholders `{unit}` and `{version}`.
12///
13/// The template holds `{version}` and no other placeholder.
14#[derive(Clone, Debug, Eq, Facet, PartialEq)]
15#[facet(proxy = String)]
16pub struct CommitSubject(String);
17
18/// A subject template is not valid.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
20pub enum CommitSubjectError {
21    /// The template has no `{version}` placeholder.
22    #[error("the commit subject has no {{version}} placeholder")]
23    MissingVersion,
24    /// The template holds a placeholder other than `{unit}` and `{version}`.
25    #[error("the commit subject holds an unknown placeholder at byte {offset}")]
26    UnknownPlaceholder {
27        /// Where the placeholder starts.
28        offset: usize,
29    },
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33enum Segment<'a> {
34    Literal(&'a str),
35    Unit,
36    Version,
37}
38
39impl CommitSubject {
40    /// The subject for `units` moving to `version`.
41    pub(crate) fn render(&self, units: &[&str], version: impl Display) -> String {
42        let units = units.join(", ");
43        let version = version.to_string();
44        self.segments()
45            .map(|segment| match segment {
46                Segment::Literal(text) => text,
47                Segment::Unit => &units,
48                Segment::Version => &version,
49            })
50            .collect()
51    }
52
53    /// Whether `subject` is this template with some unit and version.
54    pub(crate) fn matches(&self, subject: &str) -> bool {
55        let segments: Vec<_> = self.segments().collect();
56        Self::matches_from(&segments, subject)
57    }
58
59    fn matches_from(segments: &[Segment<'_>], subject: &str) -> bool {
60        match segments.split_first() {
61            None => subject.is_empty(),
62            Some((Segment::Literal(text), rest)) => subject
63                .strip_prefix(text)
64                .is_some_and(|remainder| Self::matches_from(rest, remainder)),
65            Some((Segment::Unit | Segment::Version, rest)) => subject
66                .char_indices()
67                .skip(1)
68                .map(|(end, _)| end)
69                .chain((!subject.is_empty()).then_some(subject.len()))
70                .filter_map(|end| subject.get(end..))
71                .any(|remainder| Self::matches_from(rest, remainder)),
72        }
73    }
74
75    fn segments(&self) -> impl Iterator<Item = Segment<'_>> {
76        let mut rest = self.0.as_str();
77        iter::from_fn(move || {
78            if rest.is_empty() {
79                return None;
80            }
81            let segment = if let Some(after) = rest.strip_prefix(UNIT) {
82                rest = after;
83                Segment::Unit
84            } else if let Some(after) = rest.strip_prefix(VERSION) {
85                rest = after;
86                Segment::Version
87            } else {
88                let end = rest.find('{').unwrap_or(rest.len());
89                let (literal, after) = rest.split_at(end);
90                rest = after;
91                Segment::Literal(literal)
92            };
93            Some(segment)
94        })
95    }
96}
97
98impl TryFrom<String> for CommitSubject {
99    type Error = CommitSubjectError;
100
101    fn try_from(template: String) -> Result<Self, Self::Error> {
102        let mut has_version = false;
103        for (offset, _) in template.match_indices('{') {
104            let rest = template.get(offset..).unwrap_or_default();
105            if rest.starts_with(VERSION) {
106                has_version = true;
107            } else if !rest.starts_with(UNIT) {
108                return Err(CommitSubjectError::UnknownPlaceholder { offset });
109            }
110        }
111        if has_version {
112            Ok(Self(template))
113        } else {
114            Err(CommitSubjectError::MissingVersion)
115        }
116    }
117}
118
119impl TryFrom<&str> for CommitSubject {
120    type Error = CommitSubjectError;
121
122    fn try_from(template: &str) -> Result<Self, Self::Error> {
123        Self::try_from(template.to_owned())
124    }
125}
126
127impl From<&CommitSubject> for String {
128    fn from(subject: &CommitSubject) -> Self {
129        subject.0.clone()
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{CommitSubject, CommitSubjectError};
136
137    fn subject(template: &str) -> CommitSubject {
138        CommitSubject::try_from(template).unwrap()
139    }
140
141    #[test]
142    fn the_template_needs_a_version_and_no_other_placeholder() {
143        assert_eq!(
144            CommitSubject::try_from("release: {unit}"),
145            Err(CommitSubjectError::MissingVersion)
146        );
147        assert_eq!(
148            CommitSubject::try_from("release {unit} {version} {other}"),
149            Err(CommitSubjectError::UnknownPlaceholder { offset: 25 })
150        );
151        assert!(CommitSubject::try_from("release {version}").is_ok());
152    }
153
154    #[test]
155    fn rendering_fills_the_placeholders() {
156        let template = subject("chore(release): {unit} {version}");
157        assert_eq!(
158            template.render(&["app", "lib"], "1.2.3"),
159            "chore(release): app, lib 1.2.3"
160        );
161    }
162
163    #[test]
164    fn matching_accepts_any_unit_and_version() {
165        let template = subject("chore(release): {unit} {version}");
166        assert!(template.matches("chore(release): app 1.2.3"));
167        assert!(template.matches("chore(release): app, lib 1.2.3"));
168        assert!(!template.matches("chore(release): 1.2.3"));
169        assert!(!template.matches("chore(release): app "));
170        assert!(!template.matches("fix: app 1.2.3"));
171        assert!(template.matches("chore(release): update proposal"));
172        assert!(subject("{version}").matches("1.0.0"));
173        assert!(!subject("{version}").matches(""));
174    }
175}