1use crate::error::{Error, Result};
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct TemplateName(String);
10
11impl TemplateName {
12 pub fn new(value: impl Into<String>) -> Result<Self> {
14 let value = value.into();
15 if value.is_empty()
16 || !value
17 .bytes()
18 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
19 {
20 return Err(Error::InvalidTemplateName(value));
21 }
22 Ok(Self(value))
23 }
24
25 #[must_use]
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl fmt::Display for TemplateName {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(&self.0)
35 }
36}
37
38impl FromStr for TemplateName {
39 type Err = Error;
40
41 fn from_str(value: &str) -> Result<Self> {
42 Self::new(value)
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum TemplateKind {
49 Item,
51 Sprint,
53}
54
55impl TemplateKind {
56 #[must_use]
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Item => "item",
61 Self::Sprint => "sprint",
62 }
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use crate::error::Error;
70
71 #[test]
72 fn template_name_accepts_safe_file_stems_and_rejects_paths() {
73 assert_eq!(TemplateName::new("story-1").unwrap().as_str(), "story-1");
74 for invalid in ["", "../secret", "feature/one", "with space", "日本語"] {
75 assert_eq!(
76 TemplateName::new(invalid),
77 Err(Error::InvalidTemplateName(invalid.to_string()))
78 );
79 }
80 }
81}