1use crate::error::{Error, Result};
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct ItemId {
10 prefix: String,
11 number: u32,
12}
13
14impl ItemId {
15 pub fn try_new(prefix: impl Into<String>, number: u32) -> Result<Self> {
32 let prefix = prefix.into();
33 if !is_safe_prefix(&prefix) {
34 return Err(Error::InvalidItemId(format!("{prefix}-{number}")));
35 }
36 Ok(Self { prefix, number })
37 }
38
39 #[cfg(test)]
41 pub(crate) fn new(prefix: impl Into<String>, number: u32) -> Self {
42 let prefix = prefix.into();
43 debug_assert!(is_safe_prefix(&prefix), "ItemId prefix must be validated");
44 Self { prefix, number }
45 }
46
47 #[must_use]
49 pub fn prefix(&self) -> &str {
50 &self.prefix
51 }
52
53 #[must_use]
55 pub fn number(&self) -> u32 {
56 self.number
57 }
58}
59
60impl fmt::Display for ItemId {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 write!(f, "{}-{}", self.prefix, self.number)
63 }
64}
65
66impl FromStr for ItemId {
67 type Err = Error;
68
69 fn from_str(s: &str) -> Result<Self> {
71 let invalid = || Error::InvalidItemId(s.to_string());
72 let (prefix, number) = s.rsplit_once('-').ok_or_else(invalid)?;
73 if !is_safe_prefix(prefix)
74 || number.is_empty()
75 || !number.bytes().all(|b| b.is_ascii_digit())
76 {
77 return Err(invalid());
78 }
79 let number: u32 = number.parse().map_err(|_| invalid())?;
80 ItemId::try_new(prefix, number).map_err(|_| invalid())
81 }
82}
83
84fn is_safe_prefix(prefix: &str) -> bool {
86 !prefix.is_empty() && prefix.bytes().all(|byte| byte.is_ascii_alphabetic())
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn item_id_display_and_parse_roundtrip() {
95 let id = ItemId::new("T", 42);
96 assert_eq!(id.to_string(), "T-42");
97 assert_eq!("T-42".parse::<ItemId>().unwrap(), id);
98 }
99
100 #[test]
101 fn item_id_parse_reads_prefix_and_number() {
102 let id: ItemId = "PROJ-7".parse().unwrap();
103 assert_eq!(id.prefix(), "PROJ");
104 assert_eq!(id.number(), 7);
105 }
106
107 #[test]
108 fn try_new_accepts_ascii_letters_only() {
109 for prefix in [
110 "",
111 "123",
112 "p9",
113 "bug_fix",
114 "PROJ-1",
115 "../outside",
116 "/tmp/outside",
117 "T\\outside",
118 "T.outside",
119 "T space",
120 ] {
121 assert!(
122 ItemId::try_new(prefix, 1).is_err(),
123 "expected unsafe prefix {prefix:?} to be rejected"
124 );
125 }
126
127 for prefix in ["T", "PROJ", "bugfix"] {
128 assert!(
129 ItemId::try_new(prefix, 1).is_ok(),
130 "expected safe prefix {prefix:?} to be accepted"
131 );
132 }
133 assert!(
134 ItemId::try_new("日本", 1).is_err(),
135 "non-ASCII prefix must be rejected"
136 );
137 }
138
139 #[test]
140 fn item_id_rejects_malformed() {
141 for bad in [
142 "",
143 "T",
144 "T-",
145 "-1",
146 "T-abc",
147 "T-1-2extra ",
148 "123-1",
149 "p9-1",
150 "bug_fix-1",
151 "PROJ-1-1",
152 "T- 1",
153 "../outside-1",
154 "/tmp/outside-1",
155 "T\\\\outside-1",
156 "T.outside-1",
157 ] {
158 assert!(
159 bad.parse::<ItemId>().is_err(),
160 "expected {bad:?} to be rejected"
161 );
162 }
163 }
164}