ephemeris_release/
commit_subject.rs1use std::fmt::Display;
4use std::iter;
5
6use facet::Facet;
7
8const UNIT: &str = "{unit}";
9const VERSION: &str = "{version}";
10
11#[derive(Clone, Debug, Eq, Facet, PartialEq)]
15#[facet(proxy = String)]
16pub struct CommitSubject(String);
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
20pub enum CommitSubjectError {
21 #[error("the commit subject has no {{version}} placeholder")]
23 MissingVersion,
24 #[error("the commit subject holds an unknown placeholder at byte {offset}")]
26 UnknownPlaceholder {
27 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 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 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}