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        // Validate path characters
37        self.validate_path_characters(item)?;
38
39        match &item.item {
40            FilesystemItem::Directory { path, children, .. } => {
41                // Directory paths should not contain the trailing slash in our internal representation
42                // (it's stripped during parsing, but this is a double-check)
43                if path.ends_with('/') {
44                    return Err(SyntaxError::InvalidPathFormat {
45                        line: item.line_number,
46                        path: path.clone(),
47                    }
48                    .into());
49                }
50
51                // Validate children recursively
52                for child in children {
53                    self.validate_item(child)?;
54                }
55            }
56            FilesystemItem::File { path, .. } | FilesystemItem::Symlink { path, .. } => {
57                // Files and symlinks should not end with slash
58                if path.ends_with('/') {
59                    return Err(SyntaxError::InvalidPathFormat {
60                        line: item.line_number,
61                        path: path.clone(),
62                    }
63                    .into());
64                }
65            }
66        }
67
68        Ok(())
69    }
70
71    /// Validate path characters
72    fn validate_path_characters(&self, item: &NavigationGuideLine) -> Result<()> {
73        let path = item.path();
74
75        // Check for empty path
76        if path.is_empty() {
77            return Err(SyntaxError::InvalidPathFormat {
78                line: item.line_number,
79                path: path.to_string(),
80            }
81            .into());
82        }
83
84        // Check for invalid characters
85        // Allow alphanumeric, dash, underscore, dot, and forward slash
86        for ch in path.chars() {
87            if !ch.is_alphanumeric()
88                && !matches!(
89                    ch,
90                    '-' | '_'
91                        | '.'
92                        | '/'
93                        | ' '
94                        | '('
95                        | ')'
96                        | '['
97                        | ']'
98                        | '{'
99                        | '}'
100                        | '@'
101                        | '+'
102                        | '~'
103                        | ','
104                )
105            {
106                return Err(SyntaxError::InvalidPathFormat {
107                    line: item.line_number,
108                    path: path.to_string(),
109                }
110                .into());
111            }
112        }
113
114        // Check for double slashes
115        if path.contains("//") {
116            return Err(SyntaxError::InvalidPathFormat {
117                line: item.line_number,
118                path: path.to_string(),
119            }
120            .into());
121        }
122
123        // Check for paths starting or ending with slash (should have been handled in parsing)
124        if path.starts_with('/') || path.ends_with('/') {
125            return Err(SyntaxError::InvalidPathFormat {
126                line: item.line_number,
127                path: path.to_string(),
128            }
129            .into());
130        }
131
132        Ok(())
133    }
134
135    /// Validate indentation consistency across items
136    fn validate_indentation(&self, items: &[NavigationGuideLine]) -> Result<()> {
137        if items.is_empty() {
138            return Ok(());
139        }
140
141        // Collect all unique indent levels
142        let mut indent_levels: HashSet<usize> = HashSet::new();
143        self.collect_indent_levels(items, &mut indent_levels);
144
145        // Check that all indentation levels are consistent
146        // First, find the base indentation unit (smallest non-zero indent)
147        let base_indent = indent_levels
148            .iter()
149            .filter(|&&level| level > 0)
150            .min()
151            .copied();
152
153        if let Some(base) = base_indent {
154            // All indent levels should be multiples of the base
155            for &level in &indent_levels {
156                if level > 0 && level % base != 0 {
157                    // Find the first item with this indent level to report the error
158                    if let Some(item) = self.find_item_with_indent(items, level) {
159                        return Err(SyntaxError::InconsistentIndentation {
160                            line: item.line_number,
161                            expected: ((level / base) + 1) * base,
162                            found: level,
163                        }
164                        .into());
165                    }
166                }
167            }
168        }
169
170        // Validate proper nesting (no skipping levels)
171        self.validate_nesting(items)?;
172
173        Ok(())
174    }
175
176    /// Collect all indent levels from items and their children
177    fn collect_indent_levels(&self, items: &[NavigationGuideLine], levels: &mut HashSet<usize>) {
178        for item in items {
179            levels.insert(item.indent_level);
180            if let Some(children) = item.children() {
181                self.collect_indent_levels(children, levels);
182            }
183        }
184    }
185
186    /// Find the first item with the given indent level
187    fn find_item_with_indent<'a>(
188        &self,
189        items: &'a [NavigationGuideLine],
190        target_level: usize,
191    ) -> Option<&'a NavigationGuideLine> {
192        for item in items {
193            if item.indent_level == target_level {
194                return Some(item);
195            }
196            if let Some(children) = item.children() {
197                if let Some(found) = self.find_item_with_indent(children, target_level) {
198                    return Some(found);
199                }
200            }
201        }
202        None
203    }
204
205    /// Validate that indentation levels don't skip (e.g., 0 -> 2 without 1)
206    fn validate_nesting(&self, items: &[NavigationGuideLine]) -> Result<()> {
207        for item in items {
208            if let Some(children) = item.children() {
209                for child in children {
210                    // Children should be exactly one level deeper than parent
211                    if child.indent_level != item.indent_level + 1 {
212                        return Err(SyntaxError::InvalidIndentationLevel {
213                            line: child.line_number,
214                        }
215                        .into());
216                    }
217                    // Recursively check children
218                    self.validate_nesting(children)?;
219                }
220            }
221        }
222        Ok(())
223    }
224}
225
226impl Default for Validator {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_validate_empty_guide() {
238        let guide = NavigationGuide::new();
239        let validator = Validator::new();
240        let result = validator.validate_syntax(&guide);
241        assert!(matches!(
242            result,
243            Err(crate::errors::AppError::Syntax(
244                SyntaxError::EmptyGuideBlock
245            ))
246        ));
247    }
248
249    #[test]
250    fn test_validate_invalid_path_characters() {
251        let mut guide = NavigationGuide::new();
252        guide.items.push(NavigationGuideLine {
253            line_number: 1,
254            indent_level: 0,
255            item: FilesystemItem::File {
256                path: "file|with|pipes.txt".to_string(),
257                comment: None,
258            },
259        });
260
261        let validator = Validator::new();
262        let result = validator.validate_syntax(&guide);
263        assert!(matches!(
264            result,
265            Err(crate::errors::AppError::Syntax(
266                SyntaxError::InvalidPathFormat { .. }
267            ))
268        ));
269    }
270
271    #[test]
272    fn test_validate_double_slashes() {
273        let mut guide = NavigationGuide::new();
274        guide.items.push(NavigationGuideLine {
275            line_number: 1,
276            indent_level: 0,
277            item: FilesystemItem::File {
278                path: "path//with//double//slashes.txt".to_string(),
279                comment: None,
280            },
281        });
282
283        let validator = Validator::new();
284        let result = validator.validate_syntax(&guide);
285        assert!(matches!(
286            result,
287            Err(crate::errors::AppError::Syntax(
288                SyntaxError::InvalidPathFormat { .. }
289            ))
290        ));
291    }
292}