1use regex::Regex;
6
7use super::{SLUG, template};
8use crate::{
9 Problem,
10 grammar::{LifecycleDimension, LineTemplate, Segment, States},
11 text::{self, is_slug},
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Token {
17 Literal(String),
19 Seq(usize),
21 Slug,
23 Key {
25 name: String,
27 states: Vec<String>,
29 },
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct IdTemplate {
35 pub source: String,
37 pub tokens: Vec<Token>,
39}
40
41impl IdTemplate {
42 pub fn from_template(
52 template: &LineTemplate,
53 width: usize,
54 lifecycle: &[LifecycleDimension],
55 ) -> Result<Self, Problem> {
56 let source = &template.source;
57 let mut tokens = Vec::new();
58 let mut seqs = 0;
59 for segment in &template.segments {
60 match segment {
61 Segment::Literal(l) => tokens.push(Token::Literal(l.clone())),
62 Segment::Hole { ty, .. } if ty == "seq" => {
63 seqs += 1;
64 if seqs > 1 {
65 return Err(Problem::new(
66 "TL_114",
67 format!("template `{source}` has more than one `seq` hole"),
68 ));
69 }
70 if width == 0 {
71 return Err(Problem::new(
72 "TL_102",
73 format!("template `{source}`: `seq-width` must be positive"),
74 ));
75 }
76 tokens.push(Token::Seq(width));
77 }
78 Segment::Hole { ty, .. } if ty == "slug" => tokens.push(Token::Slug),
79 Segment::Hole {
80 name: Some(name),
81 ty,
82 ..
83 } if ty == "text" => {
84 let Some(dim) = lifecycle.iter().find(|d| &d.key == name) else {
85 return Err(Problem::new(
86 "TL_58",
87 format!(
88 "template `{source}`: the hole `${{{name}}}` names no lifecycle key"
89 ),
90 ));
91 };
92 if let Some(bad) = dim.states.iter().find(|s| !is_slug(s)) {
93 return Err(Problem::new(
94 "TL_122",
95 format!(
96 "template `{source}`: the state `{bad}` of `{name}` is not a slug, so no id can hold it"
97 ),
98 ));
99 }
100 tokens.push(Token::Key {
101 name: name.clone(),
102 states: dim.states.clone(),
103 });
104 }
105 Segment::Hole { ty, .. } => {
106 return Err(Problem::new(
107 "TL_58",
108 format!(
109 "template `{source}`: an id holds only the holes a new document is filled with, `${{:seq}}`, `${{:slug}}` and `${{<lifecycle key>}}`, not {} `{ty}`",
110 text::article(ty)
111 ),
112 ));
113 }
114 }
115 }
116 let sample: String = tokens
117 .iter()
118 .map(|t| match t {
119 Token::Literal(s) => s.as_str(),
120 _ => "x",
121 })
122 .collect();
123 if !is_slug(&sample) {
124 return Err(Problem::new(
125 "TL_122",
126 format!("template `{source}` admits a non-slug: `{sample}` is not a slug"),
127 ));
128 }
129 Ok(IdTemplate {
130 source: source.clone(),
131 tokens,
132 })
133 }
134
135 pub fn fixed(id: &str) -> Result<Self, Problem> {
139 if !is_slug(id) {
140 return Err(Problem::new(
141 "TL_122",
142 format!("the id `{id}` is not a slug"),
143 ));
144 }
145 Ok(IdTemplate {
146 source: id.to_string(),
147 tokens: vec![Token::Literal(id.to_string())],
148 })
149 }
150
151 pub fn seq_width(&self) -> Option<usize> {
153 self.tokens.iter().find_map(|t| match t {
154 Token::Seq(n) => Some(*n),
155 _ => None,
156 })
157 }
158
159 pub fn has_slug(&self) -> bool {
161 self.tokens.contains(&Token::Slug)
162 }
163
164 pub fn has_keys(&self) -> bool {
166 self.tokens.iter().any(|t| matches!(t, Token::Key { .. }))
167 }
168
169 pub fn regex(&self) -> String {
173 let mut s = String::from("^");
174 for t in &self.tokens {
175 match t {
176 Token::Literal(l) => s.push_str(®ex::escape(l)),
177 Token::Seq(n) => s.push_str(&format!("(?P<seq>[0-9]{{{n}}})")),
178 Token::Slug => s.push_str(SLUG),
179 Token::Key { name, states } => {
180 let alts: Vec<String> = states.iter().map(|s| regex::escape(s)).collect();
181 s.push_str(&format!("(?P<key_{name}>{})", alts.join("|")));
182 }
183 }
184 }
185 s.push('$');
186 s
187 }
188
189 fn compiled(&self) -> Regex {
190 template::compiled(&self.regex()).expect("template regex is built from escaped parts")
191 }
192
193 pub fn matches(&self, id: &str) -> bool {
195 self.compiled().is_match(id)
196 }
197
198 pub fn seq_of(&self, id: &str) -> Option<u64> {
201 let caps = self.compiled().captures(id)?;
202 caps.name("seq")?.as_str().parse().ok()
203 }
204
205 pub fn keys_of(&self, id: &str) -> Vec<(String, String)> {
208 let Some(caps) = self.compiled().captures(id) else {
209 return vec![];
210 };
211 self.tokens
212 .iter()
213 .filter_map(|t| match t {
214 Token::Key { name, .. } => caps
215 .name(&format!("key_{name}"))
216 .map(|m| (name.clone(), m.as_str().to_string())),
217 _ => None,
218 })
219 .collect()
220 }
221
222 pub fn render(&self, seq: u64, slug: Option<&str>) -> Result<String, String> {
225 self.render_with(seq, slug, &States::default())
226 }
227
228 pub fn render_with(
233 &self,
234 seq: u64,
235 slug: Option<&str>,
236 states: &States,
237 ) -> Result<String, String> {
238 let mut s = String::new();
239 for t in &self.tokens {
240 match t {
241 Token::Literal(l) => s.push_str(l),
242 Token::Seq(n) => {
243 let digits = format!("{seq:0width$}", width = n);
244 if digits.len() > *n {
245 return Err(format!("the next number {seq} needs more than {n} digits"));
246 }
247 s.push_str(&digits);
248 }
249 Token::Slug => match slug {
250 Some(slug) if is_slug(slug) => s.push_str(slug),
251 Some(slug) => return Err(format!("`{slug}` is not slug words")),
252 None => return Err("the template has a `slug` hole; pass --slug".into()),
253 },
254 Token::Key { name, states: all } => {
255 let state = states
256 .0
257 .get(name)
258 .and_then(|s| s.first())
259 .or_else(|| all.first())
260 .cloned()
261 .unwrap_or_default();
262 if !all.contains(&state) {
263 return Err(format!("`{state}` is not a state of `{name}`"));
264 }
265 s.push_str(&state);
266 }
267 }
268 }
269 Ok(s)
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use proptest::prelude::*;
276
277 use super::*;
278 use crate::grammar::TypeRegistry;
279
280 fn parse(src: &str, width: usize) -> Result<IdTemplate, String> {
281 parse_in(src, width, &[])
282 }
283
284 fn parse_in(
285 src: &str,
286 width: usize,
287 lifecycle: &[LifecycleDimension],
288 ) -> Result<IdTemplate, String> {
289 let t = LineTemplate::parse(src, &TypeRegistry::built_ins()).map_err(|e| e.message)?;
290 IdTemplate::from_template(&t, width, lifecycle).map_err(|e| e.message)
291 }
292
293 fn kind(states: &[&str]) -> LifecycleDimension {
294 LifecycleDimension {
295 key: "kind".into(),
296 states: states.iter().map(|s| s.to_string()).collect(),
297 guidance: None,
298 line: 1,
299 extensions: Default::default(),
300 }
301 }
302
303 #[test]
305 fn a_lifecycle_key_fills_its_hole() {
306 let kinds = [kind(&["feature", "bug", "chore"])];
307 let t = parse_in("issue-${:seq}-${kind}-${:slug}", 3, &kinds).unwrap();
308 assert!(t.has_keys());
309 assert!(t.matches("issue-004-bug-retry-logic"));
310 assert!(!t.matches("issue-004-task-retry-logic"));
311 assert_eq!(t.seq_of("issue-004-bug-retry-logic"), Some(4));
312 assert_eq!(
313 t.keys_of("issue-004-bug-retry-logic"),
314 vec![("kind".to_string(), "bug".to_string())]
315 );
316 let mut states = States::default();
317 states.0.insert("kind".into(), vec!["chore".into()]);
318 assert_eq!(
319 t.render_with(7, Some("tidy"), &states).unwrap(),
320 "issue-007-chore-tidy"
321 );
322 assert_eq!(t.render(7, Some("tidy")).unwrap(), "issue-007-feature-tidy");
323 states.0.insert("kind".into(), vec!["task".into()]);
324 assert!(t.render_with(7, Some("tidy"), &states).is_err());
325 assert!(parse_in("issue-${:seq}-${state}-${:slug}", 3, &kinds).is_err());
327 assert!(parse_in("issue-${:seq}-${kind}", 3, &[kind(&["Bug"])]).is_err());
328 }
329
330 #[test]
331 fn parses_templates() {
332 let t = parse("issue-${:seq}-${:slug}", 3).unwrap();
333 assert_eq!(
334 t.tokens,
335 vec![
336 Token::Literal("issue-".into()),
337 Token::Seq(3),
338 Token::Literal("-".into()),
339 Token::Slug
340 ]
341 );
342 assert_eq!(t.seq_width(), Some(3));
343 assert!(t.has_slug());
344 assert_eq!(
345 t.regex(),
346 "^issue\\-(?P<seq>[0-9]{3})\\-[a-z0-9]+(?:-[a-z0-9]+)*$"
347 );
348 assert!(t.matches("issue-004-retry-logic"));
349 assert!(!t.matches("issue-4-retry"));
350 assert!(!t.matches("issue-004-Retry"));
351 assert_eq!(t.seq_of("issue-004-retry-logic"), Some(4));
352 assert_eq!(t.seq_of("nope"), None);
353 assert_eq!(
354 t.render(4, Some("retry-logic")).unwrap(),
355 "issue-004-retry-logic"
356 );
357 assert!(t.render(1000, Some("x")).is_err());
358 assert!(t.render(1, None).is_err());
359 assert!(t.render(1, Some("Bad Slug")).is_err());
360 let plain = parse("adr-${:seq}", 4).unwrap();
361 assert!(!plain.has_slug());
362 assert_eq!(plain.render(12, None).unwrap(), "adr-0012");
363 assert_eq!(parse("${:slug}", 3).unwrap().seq_width(), None);
364 assert!(parse("x-${n:seq}", 2).unwrap().matches("x-07"));
366 }
367
368 #[test]
369 fn refuses_bad_templates() {
370 for (bad, width) in [
371 ("issue-${:seq}-${:seq}", 3),
372 ("issue-${:seq}", 0),
373 ("issue-${:int}", 3),
374 ("issue-${slug}", 3),
375 ("Issue-${:seq}", 3),
376 ("issue_${:slug}", 3),
377 ("issue--${:slug}", 3),
378 ("-${:slug}", 3),
379 ] {
380 assert!(parse(bad, width).is_err(), "{bad}");
381 }
382 }
383
384 fn arb_template() -> impl Strategy<Value = (String, usize)> {
385 (
386 "[a-z][a-z0-9]{0,4}",
387 1usize..5,
388 any::<bool>(),
389 any::<bool>(),
390 )
391 .prop_map(|(prefix, width, with_slug, with_kind)| {
392 let kind = if with_kind { "-${kind}" } else { "" };
393 let t = if with_slug {
394 format!("{prefix}-${{:seq}}{kind}-${{:slug}}")
395 } else {
396 format!("{prefix}-${{:seq}}{kind}")
397 };
398 (t, width)
399 })
400 }
401
402 proptest! {
404 #![proptest_config(ProptestConfig::with_cases(256))]
405
406 #[test]
407 fn render_then_seq_of_round_trips((template, width) in arb_template(), seq in 0u64..10_000, slug in "[a-z0-9]+(-[a-z0-9]+){0,2}", state in prop::sample::select(vec!["feature", "bug", "chore"])) {
408 let t = parse_in(&template, width, &[kind(&["feature", "bug", "chore"])]).unwrap();
409 let mut states = States::default();
410 states.0.insert("kind".into(), vec![state.to_string()]);
411 let rendered = t.render_with(seq, Some(&slug), &states);
412 let fits = seq.to_string().len() <= width;
413 prop_assert_eq!(rendered.is_ok(), fits);
414 if let Ok(id) = rendered {
415 prop_assert!(is_slug(&id), "{id}");
416 prop_assert!(t.matches(&id));
417 prop_assert_eq!(t.seq_of(&id), Some(seq));
418 if t.has_keys() {
419 prop_assert_eq!(t.keys_of(&id), vec![("kind".to_string(), state.to_string())]);
420 }
421 }
422 }
423 }
424}