agentic_navigation_guide/
parser.rs1use crate::errors::{Result, SyntaxError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use regex::Regex;
6
7pub struct Parser {
9 list_item_regex: Regex,
11 path_comment_regex: Regex,
13}
14
15impl Parser {
16 pub fn new() -> Self {
18 Self {
19 list_item_regex: Regex::new(r"^(\s*)-\s+(.+)$").unwrap(),
20 path_comment_regex: Regex::new(r"^([^#]+?)(?:\s*#\s*(.*))?$").unwrap(),
21 }
22 }
23
24 pub fn parse(&self, content: &str) -> Result<NavigationGuide> {
26 let (prologue, guide_content, epilogue, line_offset) = self.extract_guide_block(content)?;
28
29 let items = self.parse_guide_content(&guide_content, line_offset)?;
31
32 Ok(NavigationGuide {
33 items,
34 prologue,
35 epilogue,
36 })
37 }
38
39 fn extract_guide_block(
41 &self,
42 content: &str,
43 ) -> Result<(Option<String>, String, Option<String>, usize)> {
44 let lines: Vec<&str> = content.lines().collect();
45 let mut start_idx = None;
46 let mut end_idx = None;
47
48 for (idx, line) in lines.iter().enumerate() {
50 if line.trim() == "<agentic-navigation-guide>" {
51 if start_idx.is_some() {
52 return Err(SyntaxError::MultipleGuideBlocks { line: idx + 1 }.into());
53 }
54 start_idx = Some(idx);
55 } else if line.trim() == "</agentic-navigation-guide>" {
56 end_idx = Some(idx);
57 break;
58 }
59 }
60
61 let start = start_idx.ok_or(SyntaxError::MissingOpeningMarker { line: 1 })?;
63 let end = end_idx.ok_or(SyntaxError::MissingClosingMarker { line: lines.len() })?;
64
65 let prologue = if start > 0 {
67 Some(lines[..start].join("\n"))
68 } else {
69 None
70 };
71
72 let guide_content = lines[start + 1..end].join("\n");
73
74 let epilogue = if end + 1 < lines.len() {
75 Some(lines[end + 1..].join("\n"))
76 } else {
77 None
78 };
79
80 let line_offset = start + 1;
82
83 Ok((prologue, guide_content, epilogue, line_offset))
84 }
85
86 fn parse_guide_content(
88 &self,
89 content: &str,
90 line_offset: usize,
91 ) -> Result<Vec<NavigationGuideLine>> {
92 if content.trim().is_empty() {
93 return Err(SyntaxError::EmptyGuideBlock.into());
94 }
95
96 let mut items = Vec::new();
97 let mut indent_size = None;
98 let lines: Vec<&str> = content.lines().collect();
99
100 for (idx, line) in lines.iter().enumerate() {
101 let line_number = idx + 1 + line_offset;
103
104 if line.trim().is_empty() {
106 return Err(SyntaxError::BlankLineInGuide { line: line_number }.into());
107 }
108
109 if let Some(captures) = self.list_item_regex.captures(line) {
111 let indent = captures.get(1).unwrap().as_str().len();
112 let content = captures.get(2).unwrap().as_str();
113
114 if indent > 0 && indent_size.is_none() {
116 indent_size = Some(indent);
117 }
118
119 let indent_level = if indent == 0 {
121 0
122 } else if let Some(size) = indent_size {
123 if indent % size != 0 {
124 return Err(
125 SyntaxError::InvalidIndentationLevel { line: line_number }.into()
126 );
127 }
128 indent / size
129 } else {
130 1
132 };
133
134 let (path, comment) = self.parse_path_comment(content, line_number)?;
136
137 let item = if path.ends_with('/') {
139 FilesystemItem::Directory {
140 path: path.trim_end_matches('/').to_string(),
141 comment,
142 children: Vec::new(),
143 }
144 } else {
145 FilesystemItem::File { path, comment }
147 };
148
149 items.push(NavigationGuideLine {
150 line_number,
151 indent_level,
152 item,
153 });
154 } else {
155 return Err(SyntaxError::InvalidListFormat { line: line_number }.into());
156 }
157 }
158
159 let hierarchical_items = self.build_hierarchy(items)?;
161
162 Ok(hierarchical_items)
163 }
164
165 fn parse_path_comment(
167 &self,
168 content: &str,
169 line_number: usize,
170 ) -> Result<(String, Option<String>)> {
171 if let Some(captures) = self.path_comment_regex.captures(content) {
172 let path = captures.get(1).unwrap().as_str().trim().to_string();
173 let comment = captures.get(2).map(|m| m.as_str().trim().to_string());
174
175 if path.is_empty() {
177 return Err(SyntaxError::InvalidPathFormat {
178 line: line_number,
179 path: String::new(),
180 }
181 .into());
182 }
183
184 if path == "." || path == ".." || path == "./" || path == "../" {
186 return Err(SyntaxError::InvalidSpecialDirectory {
187 line: line_number,
188 path,
189 }
190 .into());
191 }
192
193 Ok((path, comment))
194 } else {
195 Err(SyntaxError::InvalidPathFormat {
196 line: line_number,
197 path: content.to_string(),
198 }
199 .into())
200 }
201 }
202
203 fn build_hierarchy(&self, items: Vec<NavigationGuideLine>) -> Result<Vec<NavigationGuideLine>> {
205 if items.is_empty() {
206 return Ok(Vec::new());
207 }
208
209 let mut result: Vec<NavigationGuideLine> = Vec::new();
211 let mut parent_indices: Vec<Option<usize>> = vec![None; items.len()];
212
213 for i in 0..items.len() {
215 let current_level = items[i].indent_level;
216
217 if current_level == 0 {
218 parent_indices[i] = None; } else {
220 let mut parent_found = false;
222 for j in (0..i).rev() {
223 if items[j].indent_level == current_level - 1 && items[j].is_directory() {
224 parent_indices[i] = Some(j);
225 parent_found = true;
226 break;
227 } else if items[j].indent_level < current_level - 1 {
228 break;
230 }
231 }
232
233 if !parent_found {
234 return Err(SyntaxError::InvalidIndentationLevel {
235 line: items[i].line_number,
236 }
237 .into());
238 }
239 }
240 }
241
242 let mut processed_items: Vec<Option<NavigationGuideLine>> =
245 items.into_iter().map(Some).collect();
246
247 for i in (0..processed_items.len()).rev() {
249 if let Some(item) = processed_items[i].take() {
250 if let Some(parent_idx) = parent_indices[i] {
251 if let Some(ref mut parent) = processed_items[parent_idx] {
253 match &mut parent.item {
254 FilesystemItem::Directory { children, .. } => {
255 children.insert(0, item);
257 }
258 _ => {
259 return Err(SyntaxError::InvalidIndentationLevel {
260 line: item.line_number,
261 }
262 .into());
263 }
264 }
265 }
266 } else {
267 result.insert(0, item);
269 }
270 }
271 }
272
273 Ok(result)
274 }
275}
276
277impl Default for Parser {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn test_parse_minimal_guide() {
289 let content = r#"<agentic-navigation-guide>
290- src/
291 - main.rs
292- Cargo.toml
293</agentic-navigation-guide>"#;
294
295 let parser = Parser::new();
296 let guide = parser.parse(content).unwrap();
297 assert_eq!(guide.items.len(), 2); let src_item = &guide.items[0];
301 assert!(src_item.is_directory());
302 assert_eq!(src_item.path(), "src");
303
304 if let Some(children) = src_item.children() {
305 assert_eq!(children.len(), 1);
306 assert_eq!(children[0].path(), "main.rs");
307 } else {
308 panic!("src/ should have children");
309 }
310 }
311
312 #[test]
313 fn test_missing_opening_marker() {
314 let content = r#"- src/
315</agentic-navigation-guide>"#;
316
317 let parser = Parser::new();
318 let result = parser.parse(content);
319 assert!(matches!(
320 result,
321 Err(crate::errors::AppError::Syntax(
322 SyntaxError::MissingOpeningMarker { .. }
323 ))
324 ));
325 }
326
327 #[test]
328 fn test_parse_with_comments() {
329 let content = r#"<agentic-navigation-guide>
330- src/ # source code
331- Cargo.toml # project manifest
332</agentic-navigation-guide>"#;
333
334 let parser = Parser::new();
335 let guide = parser.parse(content).unwrap();
336 assert_eq!(guide.items.len(), 2);
337 assert_eq!(guide.items[0].comment(), Some("source code"));
338 assert_eq!(guide.items[1].comment(), Some("project manifest"));
339 }
340
341 #[test]
342 fn test_trailing_whitespace_allowed() {
343 let content = r#"<agentic-navigation-guide>
344- foo.rs
345- bar.rs
346- baz/
347 - qux.rs
348</agentic-navigation-guide>"#;
349
350 let parser = Parser::new();
351 let guide = parser.parse(content).unwrap();
352 assert_eq!(guide.items.len(), 3);
353 assert_eq!(guide.items[0].path(), "foo.rs");
354 assert_eq!(guide.items[1].path(), "bar.rs");
355 assert_eq!(guide.items[2].path(), "baz");
356
357 if let Some(children) = guide.items[2].children() {
358 assert_eq!(children.len(), 1);
359 assert_eq!(children[0].path(), "qux.rs");
360 } else {
361 panic!("baz/ should have children");
362 }
363 }
364}