Skip to main content

pinto/backlog/
item_id.rs

1//! PBI ID.
2
3use crate::error::{Error, Result};
4use std::fmt;
5use std::str::FromStr;
6
7/// PBI stable identifier (`<PREFIX>-<NUMBER>`, e.g. `T-1`).
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct ItemId {
10    prefix: String,
11    number: u32,
12}
13
14impl ItemId {
15    /// Construct an ID from a validated prefix and number.
16    ///
17    /// Prefixes are ASCII letters only. Use this fallible constructor at public boundaries; the
18    /// crate-private constructor below is reserved for known-safe fixtures and values produced by
19    /// already-validated domain data.
20    ///
21    /// # Examples
22    ///
23    /// ```
24    /// use pinto::backlog::ItemId;
25    ///
26    /// let id = ItemId::try_new("T", 42).expect("safe prefix");
27    /// assert_eq!(id.prefix(), "T");
28    /// assert_eq!(id.number(), 42);
29    /// assert_eq!(id.to_string(), "T-42");
30    /// ```
31    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    /// Construct an ID from a crate-validated prefix.
40    #[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    /// Prefix (e.g. `T`).
48    #[must_use]
49    pub fn prefix(&self) -> &str {
50        &self.prefix
51    }
52
53    /// Serial number.
54    #[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    /// Parse `<PREFIX>-<NUMBER>`. Prefix is ASCII letters, number is decimal.
70    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
84/// Return whether `prefix` is a non-empty ASCII-letter project key.
85fn 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}