agentic_navigation_guide/
parser.rs

1//! Parser for navigation guide markdown files
2
3use crate::errors::{Result, SyntaxError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use regex::Regex;
6
7/// Parser for navigation guide markdown content
8pub struct Parser {
9    /// Regular expression for detecting list items
10    list_item_regex: Regex,
11    /// Regular expression for parsing path and comment
12    path_comment_regex: Regex,
13}
14
15impl Parser {
16    /// Create a new parser instance
17    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    /// Parse navigation guide content from a markdown string
25    pub fn parse(&self, content: &str) -> Result<NavigationGuide> {
26        // Find the guide block
27        let (prologue, guide_content, epilogue, line_offset, ignore) =
28            self.extract_guide_block(content)?;
29
30        // Parse the guide content
31        let items = self.parse_guide_content(&guide_content, line_offset)?;
32
33        Ok(NavigationGuide {
34            items,
35            prologue,
36            epilogue,
37            ignore,
38        })
39    }
40
41    /// Extract the guide block from the markdown content
42    #[allow(clippy::type_complexity)]
43    fn extract_guide_block(
44        &self,
45        content: &str,
46    ) -> Result<(Option<String>, String, Option<String>, usize, bool)> {
47        let lines: Vec<&str> = content.lines().collect();
48        let mut start_idx = None;
49        let mut end_idx = None;
50        let mut ignore = false;
51
52        // Find the opening and closing markers
53        for (idx, line) in lines.iter().enumerate() {
54            let trimmed = line.trim();
55
56            // Check for opening tag with or without attributes
57            if trimmed.starts_with("<agentic-navigation-guide") && trimmed.ends_with(">") {
58                if start_idx.is_some() {
59                    return Err(SyntaxError::MultipleGuideBlocks { line: idx + 1 }.into());
60                }
61                start_idx = Some(idx);
62
63                // Parse ignore attribute if present
64                ignore = self.parse_ignore_attribute(trimmed);
65            } else if trimmed == "</agentic-navigation-guide>" {
66                end_idx = Some(idx);
67                break;
68            }
69        }
70
71        // Validate markers
72        let start = start_idx.ok_or(SyntaxError::MissingOpeningMarker { line: 1 })?;
73        let end = end_idx.ok_or(SyntaxError::MissingClosingMarker { line: lines.len() })?;
74
75        // Extract prologue, guide content, and epilogue
76        let prologue = if start > 0 {
77            Some(lines[..start].join("\n"))
78        } else {
79            None
80        };
81
82        let guide_content = lines[start + 1..end].join("\n");
83
84        let epilogue = if end + 1 < lines.len() {
85            Some(lines[end + 1..].join("\n"))
86        } else {
87            None
88        };
89
90        // Calculate line offset: prologue lines + opening tag line
91        let line_offset = start + 1;
92
93        Ok((prologue, guide_content, epilogue, line_offset, ignore))
94    }
95
96    /// Parse the ignore attribute from the opening tag
97    /// Supports both `ignore=true` and `ignore="true"` formats
98    fn parse_ignore_attribute(&self, tag: &str) -> bool {
99        // Check for ignore=true or ignore="true"
100        if tag.contains("ignore=true") || tag.contains("ignore=\"true\"") {
101            return true;
102        }
103        false
104    }
105
106    /// Parse the guide content into navigation guide lines
107    fn parse_guide_content(
108        &self,
109        content: &str,
110        line_offset: usize,
111    ) -> Result<Vec<NavigationGuideLine>> {
112        if content.trim().is_empty() {
113            return Err(SyntaxError::EmptyGuideBlock.into());
114        }
115
116        let mut items = Vec::new();
117        let mut indent_size = None;
118        let lines: Vec<&str> = content.lines().collect();
119
120        for (idx, line) in lines.iter().enumerate() {
121            // Calculate the actual line number in the file
122            let line_number = idx + 1 + line_offset;
123
124            // Check for blank lines
125            if line.trim().is_empty() {
126                return Err(SyntaxError::BlankLineInGuide { line: line_number }.into());
127            }
128
129            // Parse the list item
130            if let Some(captures) = self.list_item_regex.captures(line) {
131                let indent = captures.get(1).unwrap().as_str().len();
132                let content = captures.get(2).unwrap().as_str();
133
134                // Determine indent size from first indented item
135                if indent > 0 && indent_size.is_none() {
136                    indent_size = Some(indent);
137                }
138
139                // Validate indentation
140                let indent_level = if indent == 0 {
141                    0
142                } else if let Some(size) = indent_size {
143                    if indent % size != 0 {
144                        return Err(
145                            SyntaxError::InvalidIndentationLevel { line: line_number }.into()
146                        );
147                    }
148                    indent / size
149                } else {
150                    // First indented item
151                    1
152                };
153
154                // Parse path and comment
155                let (path, comment) = self.parse_path_comment(content, line_number)?;
156                let expanded_paths = Self::expand_wildcard_path(&path, line_number)?;
157
158                for expanded in expanded_paths {
159                    // Determine item type
160                    let item = if expanded == "..." {
161                        FilesystemItem::Placeholder {
162                            comment: comment.clone(),
163                        }
164                    } else if expanded.ends_with('/') {
165                        FilesystemItem::Directory {
166                            path: expanded.trim_end_matches('/').to_string(),
167                            comment: comment.clone(),
168                            children: Vec::new(),
169                        }
170                    } else {
171                        // Could be a file or symlink - we'll treat as file for now
172                        FilesystemItem::File {
173                            path: expanded,
174                            comment: comment.clone(),
175                        }
176                    };
177
178                    items.push(NavigationGuideLine {
179                        line_number,
180                        indent_level,
181                        item,
182                    });
183                }
184            } else {
185                return Err(SyntaxError::InvalidListFormat { line: line_number }.into());
186            }
187        }
188
189        // Build the hierarchy
190        let hierarchical_items = self.build_hierarchy(items)?;
191
192        Ok(hierarchical_items)
193    }
194
195    /// Parse path and optional comment from item content
196    fn parse_path_comment(
197        &self,
198        content: &str,
199        line_number: usize,
200    ) -> Result<(String, Option<String>)> {
201        if let Some(captures) = self.path_comment_regex.captures(content) {
202            let path = captures.get(1).unwrap().as_str().trim().to_string();
203            let comment = captures.get(2).map(|m| m.as_str().trim().to_string());
204
205            // Validate path
206            if path.is_empty() {
207                return Err(SyntaxError::InvalidPathFormat {
208                    line: line_number,
209                    path: String::new(),
210                }
211                .into());
212            }
213
214            // Check for special directories (but allow "..." placeholder)
215            if path == "..." {
216                // Allowed as placeholder
217            } else if path == "." || path == ".." || path == "./" || path == "../" {
218                return Err(SyntaxError::InvalidSpecialDirectory {
219                    line: line_number,
220                    path,
221                }
222                .into());
223            }
224
225            Ok((path, comment))
226        } else {
227            Err(SyntaxError::InvalidPathFormat {
228                line: line_number,
229                path: content.to_string(),
230            }
231            .into())
232        }
233    }
234
235    /// Process escape sequences in a string, converting escaped characters to their literal forms.
236    ///
237    /// Handles the following escape sequences:
238    /// - `\"` → `"`
239    /// - `\,` → `,`
240    /// - `\\` → `\`
241    /// - `\[` → `[`
242    /// - `\]` → `]`
243    ///
244    /// # Arguments
245    /// * `s` - The string containing escape sequences
246    ///
247    /// # Returns
248    /// A new string with escape sequences processed
249    fn process_escapes(s: &str) -> String {
250        let mut result = String::new();
251        let mut chars = s.chars().peekable();
252
253        while let Some(ch) = chars.next() {
254            if ch == '\\' {
255                if let Some(&next) = chars.peek() {
256                    // Consume the escaped character
257                    chars.next();
258                    result.push(next);
259                } else {
260                    // Trailing backslash - just include it
261                    result.push(ch);
262                }
263            } else {
264                result.push(ch);
265            }
266        }
267
268        result
269    }
270
271    /// Expand wildcard choices within a path, if present.
272    ///
273    /// This function processes paths containing choice blocks (syntax: `prefix[choice1, choice2]suffix`)
274    /// and expands them into multiple paths. It supports:
275    /// - Multiple choices separated by commas: `Foo[.h, .cpp]` → `["Foo.h", "Foo.cpp"]`
276    /// - Quoted strings to preserve commas and special chars: `Foo["a, b", c]`
277    /// - Escape sequences for literal special characters: `\,`, `\"`, `\\`, `\[`, `\]`
278    /// - Prefix and suffix around the choice block: `src[/main, /lib].rs` → `["src/main.rs", "src/lib.rs"]`
279    ///
280    /// Escape sequences are preserved during parsing and processed at the end,
281    /// ensuring consistent handling across prefix, choices, and suffix.
282    ///
283    /// # Arguments
284    /// * `path` - The path potentially containing a choice block
285    /// * `line_number` - Line number in the source file for error reporting
286    ///
287    /// # Returns
288    /// A vector of expanded paths. Returns a single-element vector if no choice block is present.
289    ///
290    /// # Errors
291    /// Returns `SyntaxError::InvalidWildcardSyntax` if:
292    /// - The choice block is malformed (unterminated, invalid escapes, etc.)
293    /// - Multiple choice blocks are present (only one is allowed per path)
294    /// - The choice block is empty or contains only whitespace
295    ///
296    /// # Examples
297    /// ```ignore
298    /// // Single expansion (no choice block)
299    /// expand_wildcard_path("foo.rs", 1) → Ok(vec!["foo.rs"])
300    ///
301    /// // Multiple choices
302    /// expand_wildcard_path("File[.h, .cpp]", 1) → Ok(vec!["File.h", "File.cpp"])
303    ///
304    /// // With prefix and suffix
305    /// expand_wildcard_path("src[/main, /lib].rs", 1) → Ok(vec!["src/main.rs", "src/lib.rs"])
306    ///
307    /// // Quoted strings and escapes
308    /// expand_wildcard_path("file[\"a, b\", \\,c]", 1) → Ok(vec!["filea, b", "file,c"])
309    /// ```
310    fn expand_wildcard_path(path: &str, line_number: usize) -> Result<Vec<String>> {
311        let mut prefix = String::new();
312        let mut suffix = String::new();
313        let mut block_content = String::new();
314
315        let mut in_block = false;
316        let mut block_found = false;
317        let mut in_quotes = false;
318        let mut iter = path.chars().peekable();
319
320        while let Some(ch) = iter.next() {
321            match ch {
322                '\\' => {
323                    let next = iter
324                        .next()
325                        .ok_or_else(|| SyntaxError::InvalidWildcardSyntax {
326                            line: line_number,
327                            path: path.to_string(),
328                            message: "incomplete escape sequence".to_string(),
329                        })?;
330
331                    // Preserve escape sequences consistently across prefix, block, and suffix
332                    if in_block {
333                        block_content.push('\\');
334                        block_content.push(next);
335                    } else if block_found {
336                        suffix.push('\\');
337                        suffix.push(next);
338                    } else {
339                        prefix.push('\\');
340                        prefix.push(next);
341                    }
342                }
343                '[' if !in_block => {
344                    if block_found {
345                        return Err(SyntaxError::InvalidWildcardSyntax {
346                            line: line_number,
347                            path: path.to_string(),
348                            message: "multiple wildcard choice blocks are not supported"
349                                .to_string(),
350                        }
351                        .into());
352                    }
353                    block_found = true;
354                    in_block = true;
355                    in_quotes = false;
356                }
357                ']' if in_block && !in_quotes => {
358                    in_block = false;
359                    in_quotes = false;
360                }
361                ']' if in_block => {
362                    block_content.push(ch);
363                }
364                '"' if in_block => {
365                    in_quotes = !in_quotes;
366                    block_content.push(ch);
367                }
368                _ => {
369                    if in_block {
370                        block_content.push(ch);
371                    } else if block_found {
372                        suffix.push(ch);
373                    } else {
374                        prefix.push(ch);
375                    }
376                }
377            }
378        }
379
380        if in_block {
381            return Err(SyntaxError::InvalidWildcardSyntax {
382                line: line_number,
383                path: path.to_string(),
384                message: "unterminated wildcard choice block".to_string(),
385            }
386            .into());
387        }
388
389        if !block_found {
390            // No wildcard block - just process escapes in the prefix and return
391            return Ok(vec![Self::process_escapes(&prefix)]);
392        }
393
394        let choices = Self::parse_choice_block(&block_content, path, line_number)?;
395        let mut results = Vec::with_capacity(choices.len());
396
397        // Process escapes in prefix and suffix once
398        let processed_prefix = Self::process_escapes(&prefix);
399        let processed_suffix = Self::process_escapes(&suffix);
400
401        for choice in choices {
402            // Process escapes in each choice and combine with prefix/suffix
403            let processed_choice = Self::process_escapes(&choice);
404            let mut expanded = processed_prefix.clone();
405            expanded.push_str(&processed_choice);
406            expanded.push_str(&processed_suffix);
407            results.push(expanded);
408        }
409
410        Ok(results)
411    }
412
413    /// Parse the contents of a wildcard choice block into individual options.
414    ///
415    /// Takes the content between `[` and `]` and splits it into individual choices.
416    /// This is a helper function for `expand_wildcard_path`.
417    ///
418    /// # Parsing Rules
419    /// - Choices are separated by commas (`,`)
420    /// - Commas inside quoted strings (`"..."`) are not treated as separators
421    /// - Whitespace outside quotes is ignored/trimmed
422    /// - Whitespace inside quotes is preserved
423    /// - Escape sequences (`\,`, `\"`, etc.) are preserved for later processing
424    /// - Quote characters (`"`) toggle quote mode but are not included in output
425    ///
426    /// # Arguments
427    /// * `content` - The string content between `[` and `]` (without the brackets)
428    /// * `path` - The full original path for error messages
429    /// * `line_number` - Line number in the source file for error reporting
430    ///
431    /// # Returns
432    /// A vector of choice strings with escape sequences still intact (to be processed by caller).
433    ///
434    /// # Errors
435    /// Returns `SyntaxError::InvalidWildcardSyntax` if:
436    /// - Quote strings are unterminated
437    /// - Escape sequences are incomplete (trailing backslash)
438    /// - The choice block is empty or all choices are empty/whitespace
439    ///
440    /// # Examples
441    /// ```ignore
442    /// parse_choice_block("a, b, c", "path", 1) → Ok(vec!["a", "b", "c"])
443    /// parse_choice_block("\"a, b\", c", "path", 1) → Ok(vec!["a, b", "c"])
444    /// parse_choice_block("\\,a, b", "path", 1) → Ok(vec!["\\,a", "b"])  // Escape preserved
445    /// parse_choice_block("  a  ,  b  ", "path", 1) → Ok(vec!["a", "b"])  // Trimmed
446    /// ```
447    fn parse_choice_block(content: &str, path: &str, line_number: usize) -> Result<Vec<String>> {
448        let mut choices = Vec::new();
449        let mut current = String::new();
450        let mut chars = content.chars().peekable();
451        let mut in_quotes = false;
452
453        while let Some(ch) = chars.next() {
454            match ch {
455                '\\' => {
456                    let next = chars
457                        .next()
458                        .ok_or_else(|| SyntaxError::InvalidWildcardSyntax {
459                            line: line_number,
460                            path: path.to_string(),
461                            message: "incomplete escape sequence".to_string(),
462                        })?;
463                    // Preserve escape sequences - they'll be processed later
464                    current.push('\\');
465                    current.push(next);
466                }
467                '"' => {
468                    in_quotes = !in_quotes;
469                }
470                ',' if !in_quotes => {
471                    choices.push(current.trim().to_string());
472                    current.clear();
473                }
474                ch if ch.is_whitespace() && !in_quotes => {
475                    // Ignore whitespace outside of quotes
476                }
477                _ => {
478                    current.push(ch);
479                }
480            }
481        }
482
483        if in_quotes {
484            return Err(SyntaxError::InvalidWildcardSyntax {
485                line: line_number,
486                path: path.to_string(),
487                message: "unterminated quoted string in wildcard choices".to_string(),
488            }
489            .into());
490        }
491
492        choices.push(current.trim().to_string());
493
494        // Validate that the choice block is not empty
495        if choices.is_empty() || choices.iter().all(|c| c.is_empty()) {
496            return Err(SyntaxError::InvalidWildcardSyntax {
497                line: line_number,
498                path: path.to_string(),
499                message: "choice block cannot be empty".to_string(),
500            }
501            .into());
502        }
503
504        Ok(choices)
505    }
506
507    /// Build a hierarchical structure from flat list items
508    fn build_hierarchy(&self, items: Vec<NavigationGuideLine>) -> Result<Vec<NavigationGuideLine>> {
509        if items.is_empty() {
510            return Ok(Vec::new());
511        }
512
513        // First pass: organize items by their parent-child relationships
514        let mut result: Vec<NavigationGuideLine> = Vec::new();
515        let mut parent_indices: Vec<Option<usize>> = vec![None; items.len()];
516
517        // Find parent index for each item
518        for i in 0..items.len() {
519            let current_level = items[i].indent_level;
520
521            if current_level == 0 {
522                parent_indices[i] = None; // Root item
523            } else {
524                // Find the nearest preceding directory at level current_level - 1
525                let mut parent_found = false;
526                for j in (0..i).rev() {
527                    if items[j].indent_level == current_level - 1 && items[j].is_directory() {
528                        parent_indices[i] = Some(j);
529                        parent_found = true;
530                        break;
531                    } else if items[j].indent_level < current_level - 1 {
532                        // Gone too far up the hierarchy
533                        break;
534                    }
535                }
536
537                if !parent_found {
538                    return Err(SyntaxError::InvalidIndentationLevel {
539                        line: items[i].line_number,
540                    }
541                    .into());
542                }
543            }
544        }
545
546        // Second pass: build the tree
547        // We need to process items in reverse order to ensure children are complete before adding to parents
548        let mut processed_items: Vec<Option<NavigationGuideLine>> =
549            items.into_iter().map(Some).collect();
550
551        // Process from last to first
552        for i in (0..processed_items.len()).rev() {
553            if let Some(item) = processed_items[i].take() {
554                if let Some(parent_idx) = parent_indices[i] {
555                    // Add this item to its parent's children
556                    if let Some(ref mut parent) = processed_items[parent_idx] {
557                        match &mut parent.item {
558                            FilesystemItem::Directory { children, .. } => {
559                                // Insert at the beginning to maintain order
560                                children.insert(0, item);
561                            }
562                            _ => {
563                                return Err(SyntaxError::InvalidIndentationLevel {
564                                    line: item.line_number,
565                                }
566                                .into());
567                            }
568                        }
569                    }
570                } else {
571                    // Root item - add to result
572                    result.insert(0, item);
573                }
574            }
575        }
576
577        Ok(result)
578    }
579}
580
581impl Default for Parser {
582    fn default() -> Self {
583        Self::new()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_parse_minimal_guide() {
593        let content = r#"<agentic-navigation-guide>
594- src/
595  - main.rs
596- Cargo.toml
597</agentic-navigation-guide>"#;
598
599        let parser = Parser::new();
600        let guide = parser.parse(content).unwrap();
601        assert_eq!(guide.items.len(), 2); // src/ and Cargo.toml at root level
602
603        // Check that src/ contains main.rs as a child
604        let src_item = &guide.items[0];
605        assert!(src_item.is_directory());
606        assert_eq!(src_item.path(), "src");
607
608        if let Some(children) = src_item.children() {
609            assert_eq!(children.len(), 1);
610            assert_eq!(children[0].path(), "main.rs");
611        } else {
612            panic!("src/ should have children");
613        }
614    }
615
616    #[test]
617    fn test_missing_opening_marker() {
618        let content = r#"- src/
619</agentic-navigation-guide>"#;
620
621        let parser = Parser::new();
622        let result = parser.parse(content);
623        assert!(matches!(
624            result,
625            Err(crate::errors::AppError::Syntax(
626                SyntaxError::MissingOpeningMarker { .. }
627            ))
628        ));
629    }
630
631    #[test]
632    fn test_parse_with_comments() {
633        let content = r#"<agentic-navigation-guide>
634- src/ # source code
635- Cargo.toml # project manifest
636</agentic-navigation-guide>"#;
637
638        let parser = Parser::new();
639        let guide = parser.parse(content).unwrap();
640        assert_eq!(guide.items.len(), 2);
641        assert_eq!(guide.items[0].comment(), Some("source code"));
642        assert_eq!(guide.items[1].comment(), Some("project manifest"));
643    }
644
645    #[test]
646    fn test_trailing_whitespace_allowed() {
647        let content = r#"<agentic-navigation-guide>
648- foo.rs  
649- bar.rs          
650- baz/     
651  - qux.rs      
652</agentic-navigation-guide>"#;
653
654        let parser = Parser::new();
655        let guide = parser.parse(content).unwrap();
656        assert_eq!(guide.items.len(), 3);
657        assert_eq!(guide.items[0].path(), "foo.rs");
658        assert_eq!(guide.items[1].path(), "bar.rs");
659        assert_eq!(guide.items[2].path(), "baz");
660
661        if let Some(children) = guide.items[2].children() {
662            assert_eq!(children.len(), 1);
663            assert_eq!(children[0].path(), "qux.rs");
664        } else {
665            panic!("baz/ should have children");
666        }
667    }
668
669    #[test]
670    fn test_parse_placeholder() {
671        let content = r#"<agentic-navigation-guide>
672- src/
673  - main.rs
674  - ... # other source files
675- docs/
676  - README.md
677  - ...
678</agentic-navigation-guide>"#;
679
680        let parser = Parser::new();
681        let guide = parser.parse(content).unwrap();
682        assert_eq!(guide.items.len(), 2); // src/ and docs/ at root level
683
684        // Check src/ contains main.rs and a placeholder
685        let src_item = &guide.items[0];
686        if let Some(children) = src_item.children() {
687            assert_eq!(children.len(), 2);
688            assert_eq!(children[0].path(), "main.rs");
689            assert!(children[1].is_placeholder());
690            assert_eq!(children[1].comment(), Some("other source files"));
691        } else {
692            panic!("src/ should have children");
693        }
694
695        // Check docs/ contains README.md and a placeholder
696        let docs_item = &guide.items[1];
697        if let Some(children) = docs_item.children() {
698            assert_eq!(children.len(), 2);
699            assert_eq!(children[0].path(), "README.md");
700            assert!(children[1].is_placeholder());
701            assert_eq!(children[1].comment(), None);
702        } else {
703            panic!("docs/ should have children");
704        }
705    }
706
707    #[test]
708    fn test_parse_ignore_attribute_unquoted() {
709        let content = r#"<agentic-navigation-guide ignore=true>
710- src/
711  - main.rs
712- Cargo.toml
713</agentic-navigation-guide>"#;
714
715        let parser = Parser::new();
716        let guide = parser.parse(content).unwrap();
717        assert!(guide.ignore);
718        assert_eq!(guide.items.len(), 2);
719    }
720
721    #[test]
722    fn test_parse_ignore_attribute_quoted() {
723        let content = r#"<agentic-navigation-guide ignore="true">
724- src/
725  - main.rs
726- Cargo.toml
727</agentic-navigation-guide>"#;
728
729        let parser = Parser::new();
730        let guide = parser.parse(content).unwrap();
731        assert!(guide.ignore);
732        assert_eq!(guide.items.len(), 2);
733    }
734
735    #[test]
736    fn test_parse_without_ignore_attribute() {
737        let content = r#"<agentic-navigation-guide>
738- src/
739  - main.rs
740- Cargo.toml
741</agentic-navigation-guide>"#;
742
743        let parser = Parser::new();
744        let guide = parser.parse(content).unwrap();
745        assert!(!guide.ignore);
746        assert_eq!(guide.items.len(), 2);
747    }
748
749    #[test]
750    fn test_parse_ignore_attribute_with_spaces() {
751        let content = r#"<agentic-navigation-guide  ignore=true  >
752- src/
753  - main.rs
754</agentic-navigation-guide>"#;
755
756        let parser = Parser::new();
757        let guide = parser.parse(content).unwrap();
758        assert!(guide.ignore);
759        assert_eq!(guide.items.len(), 1);
760    }
761
762    #[test]
763    fn test_parse_wildcard_expands_multiple_files() {
764        let content = r#"<agentic-navigation-guide>
765- FooCoordinator[.h, .cpp] # Coordinates foo interactions
766</agentic-navigation-guide>"#;
767
768        let parser = Parser::new();
769        let guide = parser.parse(content).unwrap();
770
771        assert_eq!(guide.items.len(), 2);
772        assert_eq!(guide.items[0].path(), "FooCoordinator.h");
773        assert_eq!(guide.items[1].path(), "FooCoordinator.cpp");
774        assert_eq!(
775            guide.items[0].comment(),
776            Some("Coordinates foo interactions")
777        );
778        assert_eq!(
779            guide.items[1].comment(),
780            Some("Coordinates foo interactions")
781        );
782    }
783
784    #[test]
785    fn test_parse_wildcard_with_empty_choice_and_whitespace() {
786        let content = r#"<agentic-navigation-guide>
787- Config[, .local].json
788</agentic-navigation-guide>"#;
789
790        let parser = Parser::new();
791        let guide = parser.parse(content).unwrap();
792
793        assert_eq!(guide.items.len(), 2);
794        assert_eq!(guide.items[0].path(), "Config.json");
795        assert_eq!(guide.items[1].path(), "Config.local.json");
796    }
797
798    #[test]
799    fn test_parse_wildcard_with_escapes_and_quotes() {
800        let content = r#"<agentic-navigation-guide>
801- data["with , comma", \,space, "literal []"] # variations
802</agentic-navigation-guide>"#;
803
804        let parser = Parser::new();
805        let guide = parser.parse(content).unwrap();
806
807        assert_eq!(guide.items.len(), 3);
808        // Note: Quote characters are not included in output, and whitespace outside
809        // quotes is trimmed. Inside quotes, content (including commas and spaces) is preserved.
810        // - "with , comma" → with , comma (quotes removed, content preserved)
811        // - \,space → ,space (escape processed, whitespace outside quotes trimmed)
812        // - "literal []" → literal [] (quotes removed, brackets preserved)
813        assert_eq!(guide.items[0].path(), "datawith , comma");
814        assert_eq!(guide.items[1].path(), "data,space");
815        assert_eq!(guide.items[2].path(), "dataliteral []");
816    }
817
818    #[test]
819    fn test_parse_wildcard_literal_brackets_without_expansion() {
820        let content = r#"<agentic-navigation-guide>
821- Foo\[bar\].txt
822</agentic-navigation-guide>"#;
823
824        let parser = Parser::new();
825        let guide = parser.parse(content).unwrap();
826
827        assert_eq!(guide.items.len(), 1);
828        assert_eq!(guide.items[0].path(), "Foo[bar].txt");
829    }
830
831    #[test]
832    fn test_parse_wildcard_multiple_blocks_error() {
833        let content = r#"<agentic-navigation-guide>
834- Foo[.h][.cpp]
835</agentic-navigation-guide>"#;
836
837        let parser = Parser::new();
838        let result = parser.parse(content);
839
840        assert!(matches!(
841            result,
842            Err(crate::errors::AppError::Syntax(
843                SyntaxError::InvalidWildcardSyntax { .. }
844            ))
845        ));
846    }
847
848    #[test]
849    fn test_parse_choice_block_with_quotes() {
850        let parsed =
851            Parser::parse_choice_block("\"with , comma\", \\,space, \"literal []\"", "path", 1)
852                .unwrap();
853
854        // Note: parse_choice_block now preserves escape sequences
855        // They are processed later in expand_wildcard_path
856        assert_eq!(parsed, vec!["with , comma", "\\,space", "literal []"]);
857    }
858
859    #[test]
860    fn test_expand_wildcard_with_escapes_and_quotes() {
861        let expanded =
862            Parser::expand_wildcard_path("data[\"with , comma\", \\,space, \"literal []\"]", 1)
863                .unwrap();
864
865        assert_eq!(
866            expanded,
867            vec![
868                "datawith , comma".to_string(),
869                "data,space".to_string(),
870                "dataliteral []".to_string(),
871            ]
872        );
873    }
874
875    #[test]
876    fn test_parse_wildcard_with_escaped_quotes_in_quoted_strings() {
877        let content = r#"<agentic-navigation-guide>
878- file[\"test\\\"quote\"].txt
879</agentic-navigation-guide>"#;
880
881        let parser = Parser::new();
882        let guide = parser.parse(content).unwrap();
883
884        assert_eq!(guide.items.len(), 1);
885        assert_eq!(guide.items[0].path(), r#"file"test\"quote".txt"#);
886    }
887
888    #[test]
889    fn test_parse_wildcard_empty_choice_block_error() {
890        let content = r#"<agentic-navigation-guide>
891- Foo[]
892</agentic-navigation-guide>"#;
893
894        let parser = Parser::new();
895        let result = parser.parse(content);
896
897        assert!(matches!(
898            result,
899            Err(crate::errors::AppError::Syntax(
900                SyntaxError::InvalidWildcardSyntax { .. }
901            ))
902        ));
903
904        if let Err(crate::errors::AppError::Syntax(SyntaxError::InvalidWildcardSyntax {
905            message,
906            ..
907        })) = result
908        {
909            assert_eq!(message, "choice block cannot be empty");
910        }
911    }
912
913    #[test]
914    fn test_parse_wildcard_whitespace_only_choice_block_error() {
915        let content = r#"<agentic-navigation-guide>
916- Foo[   ,  ,   ]
917</agentic-navigation-guide>"#;
918
919        let parser = Parser::new();
920        let result = parser.parse(content);
921
922        assert!(matches!(
923            result,
924            Err(crate::errors::AppError::Syntax(
925                SyntaxError::InvalidWildcardSyntax { .. }
926            ))
927        ));
928
929        if let Err(crate::errors::AppError::Syntax(SyntaxError::InvalidWildcardSyntax {
930            message,
931            ..
932        })) = result
933        {
934            assert_eq!(message, "choice block cannot be empty");
935        }
936    }
937
938    #[test]
939    fn test_parse_wildcard_complex_nested_escapes() {
940        // Test escaped quotes with actual quoted string to preserve spaces
941        let content = r#"<agentic-navigation-guide>
942- file["a \"b\" c"].txt
943</agentic-navigation-guide>"#;
944
945        let parser = Parser::new();
946        let guide = parser.parse(content).unwrap();
947
948        assert_eq!(guide.items.len(), 1);
949        // Note: Escaped quotes inside a quoted string are processed
950        assert_eq!(guide.items[0].path(), r#"filea "b" c.txt"#);
951    }
952}