synaps_cli/extensions/
validation.rs1pub const MAX_ID_LENGTH: usize = 64;
10
11pub const RESERVED_CHARS: &[char] = &[':'];
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum IdValidationError {
17 Empty,
18 TooLong { len: usize, max: usize },
19 ContainsReserved { ch: char },
20 ContainsWhitespace,
21 ContainsControl { ch: char },
22}
23
24impl std::fmt::Display for IdValidationError {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Self::Empty => write!(f, "must not be empty"),
28 Self::TooLong { len, max } => write!(f, "must be at most {max} chars (got {len})"),
29 Self::ContainsReserved { ch } => {
30 write!(f, "must not contain reserved character '{}'", ch)
31 }
32 Self::ContainsWhitespace => write!(f, "must not contain whitespace"),
33 Self::ContainsControl { ch } => {
34 write!(f, "must not contain control character U+{:04X}", *ch as u32)
35 }
36 }
37 }
38}
39
40impl std::error::Error for IdValidationError {}
41
42pub fn validate_id_segment(id: &str) -> Result<(), IdValidationError> {
44 if id.is_empty() {
45 return Err(IdValidationError::Empty);
46 }
47 if id.len() > MAX_ID_LENGTH {
48 return Err(IdValidationError::TooLong {
49 len: id.len(),
50 max: MAX_ID_LENGTH,
51 });
52 }
53 if let Some(ch) = id.chars().find(|c| c.is_control() && !c.is_whitespace()) {
54 return Err(IdValidationError::ContainsControl { ch });
55 }
56 if let Some(ch) = id.chars().find(|c| RESERVED_CHARS.contains(c)) {
57 return Err(IdValidationError::ContainsReserved { ch });
58 }
59 if id.chars().any(|c| c.is_whitespace()) {
60 return Err(IdValidationError::ContainsWhitespace);
61 }
62 Ok(())
63}
64
65pub fn sanitize_display_string(s: &str) -> String {
69 s.chars()
70 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
71 .collect()
72}
73
74pub fn validation_error(kind: &str, id: &str, err: IdValidationError) -> String {
79 format!("invalid {} '{}': {}", kind, id, err)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn empty_id_is_rejected() {
88 assert_eq!(validate_id_segment(""), Err(IdValidationError::Empty));
89 }
90
91 #[test]
92 fn single_char_id_is_accepted() {
93 assert!(validate_id_segment("a").is_ok());
94 }
95
96 #[test]
97 fn reasonable_id_is_accepted() {
98 assert!(validate_id_segment("foo-bar_baz.123").is_ok());
99 }
100
101 #[test]
102 fn over_max_length_is_rejected() {
103 let id = "a".repeat(MAX_ID_LENGTH + 1);
104 assert_eq!(
105 validate_id_segment(&id),
106 Err(IdValidationError::TooLong {
107 len: MAX_ID_LENGTH + 1,
108 max: MAX_ID_LENGTH,
109 })
110 );
111 }
112
113 #[test]
114 fn at_max_length_is_accepted() {
115 let id = "a".repeat(MAX_ID_LENGTH);
116 assert!(validate_id_segment(&id).is_ok());
117 }
118
119 #[test]
120 fn reserved_colon_is_rejected() {
121 assert_eq!(
122 validate_id_segment("foo:bar"),
123 Err(IdValidationError::ContainsReserved { ch: ':' })
124 );
125 }
126
127 #[test]
128 fn space_is_rejected() {
129 assert_eq!(
130 validate_id_segment("foo bar"),
131 Err(IdValidationError::ContainsWhitespace)
132 );
133 }
134
135 #[test]
136 fn tab_is_rejected() {
137 assert_eq!(
138 validate_id_segment("foo\tbar"),
139 Err(IdValidationError::ContainsWhitespace)
140 );
141 }
142
143 #[test]
144 fn validation_error_formats_context_and_cause() {
145 let msg = validation_error(
146 "tool",
147 "x:y",
148 IdValidationError::ContainsReserved { ch: ':' },
149 );
150 assert!(msg.contains("invalid tool 'x:y'"), "msg={msg}");
151 assert!(msg.contains("':'"), "msg={msg}");
152 }
153
154 #[test]
155 fn empty_error_displays_human_readable() {
156 let msg = format!("{}", IdValidationError::Empty);
157 assert_eq!(msg, "must not be empty");
158 }
159
160 #[test]
161 fn too_long_error_displays_lengths() {
162 let msg = format!("{}", IdValidationError::TooLong { len: 65, max: 64 });
163 assert!(msg.contains("65"));
164 assert!(msg.contains("64"));
165 }
166
167 #[test]
168 fn rejects_control_characters() {
169 assert_eq!(
170 validate_id_segment("foo\x1Bbar"),
171 Err(IdValidationError::ContainsControl { ch: '\x1B' })
172 );
173 assert_eq!(
174 validate_id_segment("foo\x07bar"),
175 Err(IdValidationError::ContainsControl { ch: '\x07' })
176 );
177 }
178
179 #[test]
180 fn sanitize_display_string_strips_controls() {
181 assert_eq!(sanitize_display_string("hello\x1B[31mworld"), "hello[31mworld");
182 assert_eq!(sanitize_display_string("ok\x07bell"), "okbell");
183 assert_eq!(sanitize_display_string("a\nb\tc"), "a\nb\tc");
185 }
186}