ito-common 0.1.27

Common utilities and error types for Ito
Documentation
//! Spec ID parsing.

use std::fmt;

use super::IdParseError;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
/// A spec identifier (directory name under `.ito/specs/`).
pub struct SpecId(String);

impl SpecId {
    /// Borrow the underlying string.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for SpecId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
/// Parsed representation of a spec identifier.
pub struct ParsedSpecId {
    /// The parsed spec id.
    pub spec_id: SpecId,
}

/// Parse a spec identifier.
///
/// This is intentionally permissive: any non-empty directory name is accepted
/// as a spec id.
pub fn parse_spec_id(input: &str) -> Result<ParsedSpecId, IdParseError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(IdParseError::new(
            "Spec ID cannot be empty",
            Some("Provide a spec ID like \"cli-init\""),
        ));
    }

    if trimmed.len() > 256 {
        return Err(IdParseError::new(
            format!("Spec ID is too long: {} bytes (max 256)", trimmed.len()),
            Some("Provide a shorter spec ID"),
        ));
    }

    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
        return Err(IdParseError::new(
            format!("Invalid spec ID format: \"{input}\""),
            Some("Spec IDs must be a single path segment and cannot contain traversal tokens"),
        ));
    }

    // TS accepts any directory name with a spec.md inside it. We treat the ID
    // as the directory name and do not normalize it.
    Ok(ParsedSpecId {
        spec_id: SpecId(trimmed.to_string()),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_spec_id_preserves_value() {
        let parsed = parse_spec_id("cli-init").unwrap();
        assert_eq!(parsed.spec_id.as_str(), "cli-init");
    }

    #[test]
    fn parse_spec_id_rejects_path_traversal_sequences() {
        let err = parse_spec_id("../secrets").expect_err("path traversal should fail");
        assert!(err.error.contains("Invalid spec ID format"));
    }
}