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 self.validate_path_characters(item)?;
38
39 match &item.item {
40 FilesystemItem::Directory { path, children, .. } => {
41 if path.ends_with('/') {
44 return Err(SyntaxError::InvalidPathFormat {
45 line: item.line_number,
46 path: path.clone(),
47 }
48 .into());
49 }
50
51 for child in children {
53 self.validate_item(child)?;
54 }
55 }
56 FilesystemItem::File { path, .. } | FilesystemItem::Symlink { path, .. } => {
57 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 fn validate_path_characters(&self, item: &NavigationGuideLine) -> Result<()> {
73 let path = item.path();
74
75 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 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 if path.contains("//") {
116 return Err(SyntaxError::InvalidPathFormat {
117 line: item.line_number,
118 path: path.to_string(),
119 }
120 .into());
121 }
122
123 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 fn validate_indentation(&self, items: &[NavigationGuideLine]) -> Result<()> {
137 if items.is_empty() {
138 return Ok(());
139 }
140
141 let mut indent_levels: HashSet<usize> = HashSet::new();
143 self.collect_indent_levels(items, &mut indent_levels);
144
145 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 for &level in &indent_levels {
156 if level > 0 && level % base != 0 {
157 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 self.validate_nesting(items)?;
172
173 Ok(())
174 }
175
176 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 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 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 if child.indent_level != item.indent_level + 1 {
212 return Err(SyntaxError::InvalidIndentationLevel {
213 line: child.line_number,
214 }
215 .into());
216 }
217 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}