Skip to main content

ito_common/id/
spec_id.rs

1//! Spec ID parsing.
2
3use std::fmt;
4
5use super::IdParseError;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8/// A spec identifier (directory name under `.ito/specs/`).
9pub struct SpecId(String);
10
11impl SpecId {
12    /// Borrow the underlying string.
13    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)]
25/// Parsed representation of a spec identifier.
26pub struct ParsedSpecId {
27    /// The parsed spec id.
28    pub spec_id: SpecId,
29}
30
31/// Parse a spec identifier.
32///
33/// This is intentionally permissive: any non-empty directory name is accepted
34/// as a spec id.
35pub 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    // TS accepts any directory name with a spec.md inside it. We treat the ID
59    // as the directory name and do not normalize it.
60    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}