agentic_navigation_guide/
validator.rs

1//! Syntax validation for navigation guides
2
3use crate::errors::{Result, SyntaxError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use std::collections::HashSet;
6
7/// Validator for navigation guide syntax
8pub struct Validator;
9
10impl Validator {
11    /// Create a new validator instance
12    pub fn new() -> Self {
13        Self
14    }
15
16    /// Validate the syntax of a navigation guide
17    pub fn validate_syntax(&self, guide: &NavigationGuide) -> Result<()> {
18        // Check for empty guide
19        if guide.items.is_empty() {
20            return Err(SyntaxError::EmptyGuideBlock.into());
21        }
22
23        // Validate each item
24        for item in &guide.items {
25            self.validate_item(item)?;
26        }
27
28        // Validate indentation consistency
29        self.validate_indentation(&guide.items)?;
30
31        Ok(())
32    }
33
34    /// Validate a single navigation guide item
35    fn validate_item(&self, item: &NavigationGuideLine) -> Result<()> {
36        match &item.item {
37            FilesystemItem::Placeholder { .. } => {
38                // Placeholders don't need path validation
39                // They will have additional validation in validate_placeholders
40            }
41            _ => {
42                // Validate path characters for non-placeholder items
43                self.validate_path_characters(item)?;
44            }
45        }
46
47        match &item.item {
48            FilesystemItem::Directory { path, children, .. } => {
49                // Directory paths should not contain the trailing slash in our internal representation
50                // (it's stripped during parsing, but this is a double-check)
51                if path.ends_with('/') {
52                    return Err(SyntaxError::InvalidPathFormat {
53                        line: item.line_number,
54                        path: path.clone(),
55                    }
56                    .into());
57                }
58
59                // Validate children recursively
60                for child in children {
61                    self.validate_item(child)?;
62                }
63
64                // Check placeholder-specific rules for children
65                self.validate_placeholder_rules(children)?;
66            }
67            FilesystemItem::File { path, .. } | FilesystemItem::Symlink { path, .. } => {
68                // Files and symlinks should not end with slash
69                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                // Placeholder-specific validation is done at the parent level
79            }
80        }
81
82        Ok(())
83    }
84
85    /// Validate path characters
86    fn validate_path_characters(&self, item: &NavigationGuideLine) -> Result<()> {
87        let path = item.path();
88
89        // Check for empty path
90        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        // Check for invalid characters
99        // Allow alphanumeric, dash, underscore, dot, and forward slash
100        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        // Check for double slashes
129        if path.contains("//") {
130            return Err(SyntaxError::InvalidPathFormat {
131                line: item.line_number,
132                path: path.to_string(),
133            }
134            .into());
135        }
136
137        // Check for paths starting or ending with slash (should have been handled in parsing)
138        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    /// Validate indentation consistency across items
150    fn validate_indentation(&self, items: &[NavigationGuideLine]) -> Result<()> {
151        if items.is_empty() {
152            return Ok(());
153        }
154
155        // Collect all unique indent levels
156        let mut indent_levels: HashSet<usize> = HashSet::new();
157        self.collect_indent_levels(items, &mut indent_levels);
158
159        // Check that all indentation levels are consistent
160        // First, find the base indentation unit (smallest non-zero indent)
161        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            // All indent levels should be multiples of the base
169            for &level in &indent_levels {
170                if level > 0 && level % base != 0 {
171                    // Find the first item with this indent level to report the error
172                    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        // Validate proper nesting (no skipping levels)
185        self.validate_nesting(items)?;
186
187        // Validate placeholder rules at root level
188        self.validate_placeholder_rules(items)?;
189
190        Ok(())
191    }
192
193    /// Validate placeholder-specific rules
194    fn validate_placeholder_rules(&self, items: &[NavigationGuideLine]) -> Result<()> {
195        // Check that placeholders are not adjacent
196        for i in 0..items.len() {
197            if items[i].is_placeholder() {
198                // Check if next item is also a placeholder
199                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                // Placeholders cannot have children (this should be enforced by parser)
207                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    /// Collect all indent levels from items and their children
220    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    /// Find the first item with the given indent level
230    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    /// Validate that indentation levels don't skip (e.g., 0 -> 2 without 1)
249    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                    // Children should be exactly one level deeper than parent
254                    if child.indent_level != item.indent_level + 1 {
255                        return Err(SyntaxError::InvalidIndentationLevel {
256                            line: child.line_number,
257                        }
258                        .into());
259                    }
260                    // Recursively check children
261                    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}