1use std::fmt;
4
5use super::IdParseError;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct SpecId(String);
10
11impl SpecId {
12 pub fn as_str(&self) -> &str {
14 &self.0
15 }
16}
17
18impl fmt::Display for SpecId {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 f.write_str(&self.0)
21 }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ParsedSpecId {
27 pub spec_id: SpecId,
29}
30
31pub fn parse_spec_id(input: &str) -> Result<ParsedSpecId, IdParseError> {
36 let trimmed = input.trim();
37 if trimmed.is_empty() {
38 return Err(IdParseError::new(
39 "Spec ID cannot be empty",
40 Some("Provide a spec ID like \"cli-init\""),
41 ));
42 }
43
44 if trimmed.len() > 256 {
45 return Err(IdParseError::new(
46 format!("Spec ID is too long: {} bytes (max 256)", trimmed.len()),
47 Some("Provide a shorter spec ID"),
48 ));
49 }
50
51 if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
52 return Err(IdParseError::new(
53 format!("Invalid spec ID format: \"{input}\""),
54 Some("Spec IDs must be a single path segment and cannot contain traversal tokens"),
55 ));
56 }
57
58 Ok(ParsedSpecId {
61 spec_id: SpecId(trimmed.to_string()),
62 })
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn parse_spec_id_preserves_value() {
71 let parsed = parse_spec_id("cli-init").unwrap();
72 assert_eq!(parsed.spec_id.as_str(), "cli-init");
73 }
74
75 #[test]
76 fn parse_spec_id_rejects_path_traversal_sequences() {
77 let err = parse_spec_id("../secrets").expect_err("path traversal should fail");
78 assert!(err.error.contains("Invalid spec ID format"));
79 }
80}