agentic_navigation_guide/
validator.rs1use crate::errors::{Result, SyntaxError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use std::collections::HashSet;
6
7pub struct Validator;
9
10impl Validator {
11 pub fn new() -> Self {
13 Self
14 }
15
16 pub fn validate_syntax(&self, guide: &NavigationGuide) -> Result<()> {
18 if guide.items.is_empty() {
20 return Err(SyntaxError::EmptyGuideBlock.into());
21 }
22
23 for item in &guide.items {
25 self.validate_item(item)?;
26 }
27
28 self.validate_indentation(&guide.items)?;
30
31 Ok(())
32 }
33
34 fn validate_item(&self, item: &NavigationGuideLine) -> Result<()> {
36 match &item.item {
37 FilesystemItem::Placeholder { .. } => {
38 }
41 _ => {
42 self.validate_path_characters(item)?;
44 }
45 }
46
47 match &item.item {
48 FilesystemItem::Directory { path, children, .. } => {
49 if path.ends_with('/') {
52 return Err(SyntaxError::InvalidPathFormat {
53 line: item.line_number,
54 path: path.clone(),
55 }
56 .into());
57 }
58
59 for child in children {
61 self.validate_item(child)?;
62 }
63
64 self.validate_placeholder_rules(children)?;
66 }
67 FilesystemItem::File { path, .. } | FilesystemItem::Symlink { path, .. } => {
68 if path.ends_with('/') {
70 return Err(SyntaxError::InvalidPathFormat {
71 line: item.line_number,
72 path: path.clone(),
73 }
74 .into());
75 }
76 }
77 FilesystemItem::Placeholder { .. } => {
78 }
80 }
81
82 Ok(())
83 }
84
85 fn validate_path_characters(&self, item: &NavigationGuideLine) -> Result<()> {
87 let path = item.path();
88
89 if path.is_empty() {
91 return Err(SyntaxError::InvalidPathFormat {
92 line: item.line_number,
93 path: path.to_string(),
94 }
95 .into());
96 }
97
98 for ch in path.chars() {
101 if !ch.is_alphanumeric()
102 && !matches!(
103 ch,
104 '-' | '_'
105 | '.'
106 | '/'
107 | ' '
108 | '('
109 | ')'
110 | '['
111 | ']'
112 | '{'
113 | '}'
114 | '@'
115 | '+'
116 | '~'
117 | ','
118 )
119 {
120 return Err(SyntaxError::InvalidPathFormat {
121 line: item.line_number,
122 path: path.to_string(),
123 }
124 .into());
125 }
126 }
127
128 if path.contains("//") {
130 return Err(SyntaxError::InvalidPathFormat {
131 line: item.line_number,
132 path: path.to_string(),
133 }
134 .into());
135 }
136
137 if path.starts_with('/') || path.ends_with('/') {
139 return Err(SyntaxError::InvalidPathFormat {
140 line: item.line_number,
141 path: path.to_string(),
142 }
143 .into());
144 }
145
146 Ok(())
147 }
148
149 fn validate_indentation(&self, items: &[NavigationGuideLine]) -> Result<()> {
151 if items.is_empty() {
152 return Ok(());
153 }
154
155 let mut indent_levels: HashSet<usize> = HashSet::new();
157 self.collect_indent_levels(items, &mut indent_levels);
158
159 let base_indent = indent_levels
162 .iter()
163 .filter(|&&level| level > 0)
164 .min()
165 .copied();
166
167 if let Some(base) = base_indent {
168 for &level in &indent_levels {
170 if level > 0 && level % base != 0 {
171 if let Some(item) = self.find_item_with_indent(items, level) {
173 return Err(SyntaxError::InconsistentIndentation {
174 line: item.line_number,
175 expected: ((level / base) + 1) * base,
176 found: level,
177 }
178 .into());
179 }
180 }
181 }
182 }
183
184 self.validate_nesting(items)?;
186
187 self.validate_placeholder_rules(items)?;
189
190 Ok(())
191 }
192
193 fn validate_placeholder_rules(&self, items: &[NavigationGuideLine]) -> Result<()> {
195 for i in 0..items.len() {
197 if items[i].is_placeholder() {
198 if i + 1 < items.len() && items[i + 1].is_placeholder() {
200 return Err(SyntaxError::AdjacentPlaceholders {
201 line: items[i + 1].line_number,
202 }
203 .into());
204 }
205
206 if items[i].children().is_some() && !items[i].children().unwrap().is_empty() {
208 return Err(SyntaxError::PlaceholderWithChildren {
209 line: items[i].line_number,
210 }
211 .into());
212 }
213 }
214 }
215
216 Ok(())
217 }
218
219 fn collect_indent_levels(&self, items: &[NavigationGuideLine], levels: &mut HashSet<usize>) {
221 for item in items {
222 levels.insert(item.indent_level);
223 if let Some(children) = item.children() {
224 self.collect_indent_levels(children, levels);
225 }
226 }
227 }
228
229 fn find_item_with_indent<'a>(
231 &self,
232 items: &'a [NavigationGuideLine],
233 target_level: usize,
234 ) -> Option<&'a NavigationGuideLine> {
235 for item in items {
236 if item.indent_level == target_level {
237 return Some(item);
238 }
239 if let Some(children) = item.children() {
240 if let Some(found) = self.find_item_with_indent(children, target_level) {
241 return Some(found);
242 }
243 }
244 }
245 None
246 }
247
248 fn validate_nesting(&self, items: &[NavigationGuideLine]) -> Result<()> {
250 for item in items {
251 if let Some(children) = item.children() {
252 for child in children {
253 if child.indent_level != item.indent_level + 1 {
255 return Err(SyntaxError::InvalidIndentationLevel {
256 line: child.line_number,
257 }
258 .into());
259 }
260 self.validate_nesting(children)?;
262 }
263 }
264 }
265 Ok(())
266 }
267}
268
269impl Default for Validator {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn test_validate_empty_guide() {
281 let guide = NavigationGuide::new();
282 let validator = Validator::new();
283 let result = validator.validate_syntax(&guide);
284 assert!(matches!(
285 result,
286 Err(crate::errors::AppError::Syntax(
287 SyntaxError::EmptyGuideBlock
288 ))
289 ));
290 }
291
292 #[test]
293 fn test_validate_invalid_path_characters() {
294 let mut guide = NavigationGuide::new();
295 guide.items.push(NavigationGuideLine {
296 line_number: 1,
297 indent_level: 0,
298 item: FilesystemItem::File {
299 path: "file|with|pipes.txt".to_string(),
300 comment: None,
301 },
302 });
303
304 let validator = Validator::new();
305 let result = validator.validate_syntax(&guide);
306 assert!(matches!(
307 result,
308 Err(crate::errors::AppError::Syntax(
309 SyntaxError::InvalidPathFormat { .. }
310 ))
311 ));
312 }
313
314 #[test]
315 fn test_validate_double_slashes() {
316 let mut guide = NavigationGuide::new();
317 guide.items.push(NavigationGuideLine {
318 line_number: 1,
319 indent_level: 0,
320 item: FilesystemItem::File {
321 path: "path//with//double//slashes.txt".to_string(),
322 comment: None,
323 },
324 });
325
326 let validator = Validator::new();
327 let result = validator.validate_syntax(&guide);
328 assert!(matches!(
329 result,
330 Err(crate::errors::AppError::Syntax(
331 SyntaxError::InvalidPathFormat { .. }
332 ))
333 ));
334 }
335
336 #[test]
337 fn test_validate_adjacent_placeholders() {
338 let mut guide = NavigationGuide::new();
339 guide.items.push(NavigationGuideLine {
340 line_number: 1,
341 indent_level: 0,
342 item: FilesystemItem::Directory {
343 path: "src".to_string(),
344 comment: None,
345 children: vec![
346 NavigationGuideLine {
347 line_number: 2,
348 indent_level: 1,
349 item: FilesystemItem::Placeholder {
350 comment: Some("first placeholder".to_string()),
351 },
352 },
353 NavigationGuideLine {
354 line_number: 3,
355 indent_level: 1,
356 item: FilesystemItem::Placeholder {
357 comment: Some("second placeholder".to_string()),
358 },
359 },
360 ],
361 },
362 });
363
364 let validator = Validator::new();
365 let result = validator.validate_syntax(&guide);
366 assert!(matches!(
367 result,
368 Err(crate::errors::AppError::Syntax(
369 SyntaxError::AdjacentPlaceholders { line: 3 }
370 ))
371 ));
372 }
373
374 #[test]
375 fn test_validate_non_adjacent_placeholders() {
376 let mut guide = NavigationGuide::new();
377 guide.items.push(NavigationGuideLine {
378 line_number: 1,
379 indent_level: 0,
380 item: FilesystemItem::Directory {
381 path: "src".to_string(),
382 comment: None,
383 children: vec![
384 NavigationGuideLine {
385 line_number: 2,
386 indent_level: 1,
387 item: FilesystemItem::Placeholder {
388 comment: Some("first placeholder".to_string()),
389 },
390 },
391 NavigationGuideLine {
392 line_number: 3,
393 indent_level: 1,
394 item: FilesystemItem::File {
395 path: "main.rs".to_string(),
396 comment: None,
397 },
398 },
399 NavigationGuideLine {
400 line_number: 4,
401 indent_level: 1,
402 item: FilesystemItem::Placeholder {
403 comment: Some("second placeholder".to_string()),
404 },
405 },
406 ],
407 },
408 });
409
410 let validator = Validator::new();
411 let result = validator.validate_syntax(&guide);
412 assert!(result.is_ok());
413 }
414}