pub const MAX_ID_LENGTH: usize = 64;
pub const RESERVED_CHARS: &[char] = &[':'];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdValidationError {
Empty,
TooLong { len: usize, max: usize },
ContainsReserved { ch: char },
ContainsWhitespace,
ContainsControl { ch: char },
}
impl std::fmt::Display for IdValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "must not be empty"),
Self::TooLong { len, max } => write!(f, "must be at most {max} chars (got {len})"),
Self::ContainsReserved { ch } => {
write!(f, "must not contain reserved character '{}'", ch)
}
Self::ContainsWhitespace => write!(f, "must not contain whitespace"),
Self::ContainsControl { ch } => {
write!(f, "must not contain control character U+{:04X}", *ch as u32)
}
}
}
}
impl std::error::Error for IdValidationError {}
pub fn validate_id_segment(id: &str) -> Result<(), IdValidationError> {
if id.is_empty() {
return Err(IdValidationError::Empty);
}
if id.len() > MAX_ID_LENGTH {
return Err(IdValidationError::TooLong {
len: id.len(),
max: MAX_ID_LENGTH,
});
}
if let Some(ch) = id.chars().find(|c| c.is_control() && !c.is_whitespace()) {
return Err(IdValidationError::ContainsControl { ch });
}
if let Some(ch) = id.chars().find(|c| RESERVED_CHARS.contains(c)) {
return Err(IdValidationError::ContainsReserved { ch });
}
if id.chars().any(|c| c.is_whitespace()) {
return Err(IdValidationError::ContainsWhitespace);
}
Ok(())
}
pub fn sanitize_display_string(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect()
}
pub fn validation_error(kind: &str, id: &str, err: IdValidationError) -> String {
format!("invalid {} '{}': {}", kind, id, err)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_id_is_rejected() {
assert_eq!(validate_id_segment(""), Err(IdValidationError::Empty));
}
#[test]
fn single_char_id_is_accepted() {
assert!(validate_id_segment("a").is_ok());
}
#[test]
fn reasonable_id_is_accepted() {
assert!(validate_id_segment("foo-bar_baz.123").is_ok());
}
#[test]
fn over_max_length_is_rejected() {
let id = "a".repeat(MAX_ID_LENGTH + 1);
assert_eq!(
validate_id_segment(&id),
Err(IdValidationError::TooLong {
len: MAX_ID_LENGTH + 1,
max: MAX_ID_LENGTH,
})
);
}
#[test]
fn at_max_length_is_accepted() {
let id = "a".repeat(MAX_ID_LENGTH);
assert!(validate_id_segment(&id).is_ok());
}
#[test]
fn reserved_colon_is_rejected() {
assert_eq!(
validate_id_segment("foo:bar"),
Err(IdValidationError::ContainsReserved { ch: ':' })
);
}
#[test]
fn space_is_rejected() {
assert_eq!(
validate_id_segment("foo bar"),
Err(IdValidationError::ContainsWhitespace)
);
}
#[test]
fn tab_is_rejected() {
assert_eq!(
validate_id_segment("foo\tbar"),
Err(IdValidationError::ContainsWhitespace)
);
}
#[test]
fn validation_error_formats_context_and_cause() {
let msg = validation_error(
"tool",
"x:y",
IdValidationError::ContainsReserved { ch: ':' },
);
assert!(msg.contains("invalid tool 'x:y'"), "msg={msg}");
assert!(msg.contains("':'"), "msg={msg}");
}
#[test]
fn empty_error_displays_human_readable() {
let msg = format!("{}", IdValidationError::Empty);
assert_eq!(msg, "must not be empty");
}
#[test]
fn too_long_error_displays_lengths() {
let msg = format!("{}", IdValidationError::TooLong { len: 65, max: 64 });
assert!(msg.contains("65"));
assert!(msg.contains("64"));
}
#[test]
fn rejects_control_characters() {
assert_eq!(
validate_id_segment("foo\x1Bbar"),
Err(IdValidationError::ContainsControl { ch: '\x1B' })
);
assert_eq!(
validate_id_segment("foo\x07bar"),
Err(IdValidationError::ContainsControl { ch: '\x07' })
);
}
#[test]
fn sanitize_display_string_strips_controls() {
assert_eq!(sanitize_display_string("hello\x1B[31mworld"), "hello[31mworld");
assert_eq!(sanitize_display_string("ok\x07bell"), "okbell");
assert_eq!(sanitize_display_string("a\nb\tc"), "a\nb\tc");
}
}