acp/annotate/converters/
rustdoc.rs

1//! @acp:module "Rust Doc Parser"
2//! @acp:summary "Parses Rust doc comments and converts to ACP format"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Rust Doc Parser
8//!
9//! Parses Rust documentation comments in standard format:
10//!
11//! ## Comment Styles
12//! - `///` - Outer documentation (items)
13//! - `//!` - Inner documentation (modules/crates)
14//!
15//! ## Standard Sections
16//! - `# Examples` - Code examples
17//! - `# Arguments` - Function parameters
18//! - `# Returns` - Return value description
19//! - `# Panics` - Panic conditions
20//! - `# Errors` - Error conditions (for Result returns)
21//! - `# Safety` - Safety requirements for unsafe code
22//! - `# Type Parameters` - Generic type parameters
23//!
24//! ## Features Detected
25//! - Intra-doc links: `[Type]`, `[method](Type::method)`
26//! - Code blocks with language hints
27//! - Deprecated markers
28//! - Must-use hints
29
30use std::sync::LazyLock;
31
32use regex::Regex;
33
34use super::{DocStandardParser, ParsedDocumentation};
35use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
36
37/// @acp:summary "Matches Markdown section headers (# Section)"
38static SECTION_HEADER: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(r"^#\s+(Examples?|Arguments?|Parameters?|Returns?|Panics?|Errors?|Safety|Type\s+Parameters?|See\s+Also|Notes?|Warnings?)\s*$")
40        .expect("Invalid section header regex")
41});
42
43/// @acp:summary "Matches intra-doc links [Name] or [`Name`]"
44static INTRA_DOC_LINK: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(r"\[`?([a-zA-Z_][a-zA-Z0-9_:]*)`?\](?:\([^)]+\))?")
46        .expect("Invalid intra-doc link regex")
47});
48
49/// @acp:summary "Matches code blocks ```rust or ```"
50static CODE_BLOCK_START: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"^```(\w*)?\s*$").expect("Invalid code block regex"));
52
53/// @acp:summary "Matches argument lines (name - description or * `name` - description)"
54static ARG_LINE: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r"^\*?\s*`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s*[-:]?\s*(.*)$")
56        .expect("Invalid argument line regex")
57});
58
59/// @acp:summary "Rust-specific extensions for doc comments"
60#[derive(Debug, Clone, Default)]
61pub struct RustDocExtensions {
62    /// Whether this is module-level documentation (//!)
63    pub is_module_doc: bool,
64
65    /// Whether the item is marked unsafe
66    pub is_unsafe: bool,
67
68    /// Whether the item returns a Result type
69    pub returns_result: bool,
70
71    /// Whether the item is async
72    pub is_async: bool,
73
74    /// Panic conditions
75    pub panics: Vec<String>,
76
77    /// Error conditions (for Result types)
78    pub errors: Vec<String>,
79
80    /// Safety requirements (for unsafe code)
81    pub safety: Vec<String>,
82
83    /// Type parameters with descriptions
84    pub type_params: Vec<(String, Option<String>)>,
85
86    /// Intra-doc links found
87    pub doc_links: Vec<String>,
88
89    /// Whether marked with #[must_use]
90    pub must_use: Option<String>,
91
92    /// Deprecation info (since, note)
93    pub deprecated_since: Option<String>,
94
95    /// Feature gate if any
96    pub feature_gate: Option<String>,
97}
98
99/// @acp:summary "Parses Rust doc comments"
100/// @acp:lock normal
101pub struct RustdocParser {
102    /// Rust-specific extensions parsed from doc comments
103    extensions: RustDocExtensions,
104}
105
106impl RustdocParser {
107    /// @acp:summary "Creates a new Rustdoc parser"
108    pub fn new() -> Self {
109        Self {
110            extensions: RustDocExtensions::default(),
111        }
112    }
113
114    /// @acp:summary "Gets the parsed Rust extensions"
115    pub fn extensions(&self) -> &RustDocExtensions {
116        &self.extensions
117    }
118
119    /// @acp:summary "Checks if this is module-level documentation"
120    fn is_module_doc(raw: &str) -> bool {
121        // Check if the raw comment uses //! style
122        raw.lines().any(|line| {
123            let trimmed = line.trim();
124            trimmed.starts_with("//!")
125        })
126    }
127
128    /// @acp:summary "Strips doc comment prefixes from lines"
129    fn strip_doc_prefix(line: &str) -> &str {
130        let trimmed = line.trim();
131        if let Some(rest) = trimmed.strip_prefix("///") {
132            rest.trim_start()
133        } else if let Some(rest) = trimmed.strip_prefix("//!") {
134            rest.trim_start()
135        } else if let Some(rest) = trimmed.strip_prefix("*") {
136            // Handle /** ... */ style if used
137            rest.trim_start()
138        } else {
139            trimmed
140        }
141    }
142
143    /// @acp:summary "Parses section content into structured data"
144    fn parse_section_content(
145        &self,
146        section: &str,
147        content: &[String],
148        doc: &mut ParsedDocumentation,
149        extensions: &mut RustDocExtensions,
150    ) {
151        let text = content.join("\n").trim().to_string();
152        if text.is_empty() {
153            return;
154        }
155
156        match section.to_lowercase().as_str() {
157            "arguments" | "argument" | "parameters" | "parameter" => {
158                // Parse argument entries
159                for param in self.parse_arguments(&text) {
160                    doc.params.push(param);
161                }
162            }
163            "returns" | "return" => {
164                doc.returns = Some((None, Some(text)));
165            }
166            "panics" | "panic" => {
167                extensions.panics.push(text.clone());
168                doc.notes.push(format!("Panics: {}", text));
169            }
170            "errors" | "error" => {
171                extensions.errors.push(text.clone());
172                extensions.returns_result = true;
173                doc.notes.push(format!("Errors: {}", text));
174            }
175            "safety" => {
176                extensions.is_unsafe = true;
177                extensions.safety.push(text.clone());
178                doc.notes.push(format!("Safety: {}", text));
179            }
180            "type parameters" | "type parameter" => {
181                // Parse type parameter entries
182                for (name, desc) in self.parse_type_params(&text) {
183                    extensions.type_params.push((name, desc));
184                }
185            }
186            "examples" | "example" => {
187                doc.examples.push(text);
188            }
189            "see also" => {
190                for ref_line in text.lines() {
191                    let ref_line = ref_line.trim();
192                    if !ref_line.is_empty() {
193                        doc.see_refs.push(ref_line.to_string());
194                    }
195                }
196            }
197            "notes" | "note" => {
198                doc.notes.push(text);
199            }
200            "warnings" | "warning" => {
201                doc.notes.push(format!("Warning: {}", text));
202            }
203            _ => {}
204        }
205    }
206
207    /// @acp:summary "Parses argument entries from text"
208    fn parse_arguments(&self, text: &str) -> Vec<(String, Option<String>, Option<String>)> {
209        let mut args = Vec::new();
210        let mut current_name: Option<String> = None;
211        let mut current_desc = Vec::new();
212
213        for line in text.lines() {
214            let trimmed = line.trim();
215            if trimmed.is_empty() {
216                continue;
217            }
218
219            // Check for list item (- or *)
220            let content = if let Some(rest) = trimmed.strip_prefix('-') {
221                rest.trim()
222            } else if let Some(rest) = trimmed.strip_prefix('*') {
223                rest.trim()
224            } else {
225                trimmed
226            };
227
228            // Try to match argument pattern
229            if let Some(caps) = ARG_LINE.captures(content) {
230                // Save previous argument
231                if let Some(name) = current_name.take() {
232                    let desc = if current_desc.is_empty() {
233                        None
234                    } else {
235                        Some(current_desc.join(" "))
236                    };
237                    args.push((name, None, desc));
238                    current_desc.clear();
239                }
240
241                let name = caps.get(1).unwrap().as_str().to_string();
242                let desc = caps.get(2).map(|m| m.as_str().trim().to_string());
243                current_name = Some(name);
244                if let Some(d) = desc {
245                    if !d.is_empty() {
246                        current_desc.push(d);
247                    }
248                }
249            } else if current_name.is_some() && (line.starts_with("  ") || line.starts_with("\t")) {
250                // Continuation of description
251                current_desc.push(trimmed.to_string());
252            }
253        }
254
255        // Save last argument
256        if let Some(name) = current_name {
257            let desc = if current_desc.is_empty() {
258                None
259            } else {
260                Some(current_desc.join(" "))
261            };
262            args.push((name, None, desc));
263        }
264
265        args
266    }
267
268    /// @acp:summary "Parses type parameter entries"
269    fn parse_type_params(&self, text: &str) -> Vec<(String, Option<String>)> {
270        let mut params = Vec::new();
271
272        for line in text.lines() {
273            let trimmed = line.trim();
274            if trimmed.is_empty() {
275                continue;
276            }
277
278            // Remove list markers
279            let content = if let Some(rest) = trimmed.strip_prefix('-') {
280                rest.trim()
281            } else if let Some(rest) = trimmed.strip_prefix('*') {
282                rest.trim()
283            } else {
284                trimmed
285            };
286
287            // Parse "T - description" or "`T` - description"
288            if let Some(caps) = ARG_LINE.captures(content) {
289                let name = caps.get(1).unwrap().as_str().to_string();
290                let desc = caps.get(2).map(|m| m.as_str().trim().to_string());
291                params.push((name, desc));
292            }
293        }
294
295        params
296    }
297
298    /// @acp:summary "Extracts intra-doc links from text"
299    fn extract_doc_links(&self, text: &str) -> Vec<String> {
300        INTRA_DOC_LINK
301            .captures_iter(text)
302            .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
303            .collect()
304    }
305
306    /// @acp:summary "Checks if the doc contains code examples"
307    fn has_code_examples(&self, raw: &str) -> bool {
308        let mut in_code_block = false;
309        for line in raw.lines() {
310            let stripped = Self::strip_doc_prefix(line);
311            if stripped.starts_with("```") {
312                in_code_block = !in_code_block;
313            }
314        }
315        raw.contains("```")
316    }
317}
318
319impl Default for RustdocParser {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325impl DocStandardParser for RustdocParser {
326    fn parse(&self, raw_comment: &str) -> ParsedDocumentation {
327        let mut doc = ParsedDocumentation::new();
328        let mut extensions = RustDocExtensions {
329            is_module_doc: Self::is_module_doc(raw_comment),
330            ..Default::default()
331        };
332
333        let mut summary_lines = Vec::new();
334        let mut current_section: Option<String> = None;
335        let mut section_content = Vec::new();
336        let mut in_code_block = false;
337        let mut first_paragraph_done = false;
338
339        for line in raw_comment.lines() {
340            let stripped = Self::strip_doc_prefix(line);
341
342            // Track code blocks (don't parse inside them)
343            if CODE_BLOCK_START.is_match(stripped) {
344                in_code_block = !in_code_block;
345                if current_section.is_some() {
346                    section_content.push(stripped.to_string());
347                }
348                continue;
349            }
350
351            if in_code_block {
352                if current_section.is_some() {
353                    section_content.push(stripped.to_string());
354                }
355                continue;
356            }
357
358            // Check for section header
359            if let Some(caps) = SECTION_HEADER.captures(stripped) {
360                // Save previous section
361                if let Some(ref section) = current_section {
362                    self.parse_section_content(
363                        section,
364                        &section_content,
365                        &mut doc,
366                        &mut extensions,
367                    );
368                }
369                section_content.clear();
370
371                current_section = Some(caps.get(1).unwrap().as_str().to_string());
372                first_paragraph_done = true;
373            } else if current_section.is_some() {
374                section_content.push(stripped.to_string());
375            } else if stripped.is_empty() {
376                // Empty line ends first paragraph (summary)
377                if !summary_lines.is_empty() {
378                    first_paragraph_done = true;
379                }
380            } else if !first_paragraph_done {
381                summary_lines.push(stripped.to_string());
382            }
383        }
384
385        // Save last section
386        if let Some(ref section) = current_section {
387            self.parse_section_content(section, &section_content, &mut doc, &mut extensions);
388        }
389
390        // Set summary and description
391        if !summary_lines.is_empty() {
392            doc.summary = Some(summary_lines[0].clone());
393            if summary_lines.len() > 1 {
394                doc.description = Some(summary_lines.join(" "));
395            }
396        }
397
398        // Extract intra-doc links
399        extensions.doc_links = self.extract_doc_links(raw_comment);
400
401        // Check for code examples
402        if self.has_code_examples(raw_comment) && doc.examples.is_empty() {
403            doc.examples.push("Has code examples".to_string());
404        }
405
406        // Store extensions info in custom tags for later use
407        if extensions.is_module_doc {
408            doc.custom_tags
409                .push(("module_doc".to_string(), "true".to_string()));
410        }
411        if extensions.is_unsafe {
412            doc.custom_tags
413                .push(("unsafe".to_string(), "true".to_string()));
414        }
415        if extensions.returns_result {
416            doc.custom_tags
417                .push(("returns_result".to_string(), "true".to_string()));
418        }
419        if !extensions.panics.is_empty() {
420            doc.custom_tags
421                .push(("has_panics".to_string(), "true".to_string()));
422        }
423        if !extensions.safety.is_empty() {
424            doc.custom_tags
425                .push(("has_safety".to_string(), "true".to_string()));
426        }
427        for link in &extensions.doc_links {
428            doc.see_refs.push(link.clone());
429        }
430
431        doc
432    }
433
434    fn standard_name(&self) -> &'static str {
435        "rustdoc"
436    }
437
438    /// @acp:summary "Converts parsed Rustdoc to ACP suggestions with Rust-specific handling"
439    fn to_suggestions(
440        &self,
441        parsed: &ParsedDocumentation,
442        target: &str,
443        line: usize,
444    ) -> Vec<Suggestion> {
445        let mut suggestions = Vec::new();
446
447        // Convert summary (truncated)
448        if let Some(summary) = &parsed.summary {
449            let truncated = truncate_for_summary(summary, 100);
450            suggestions.push(Suggestion::summary(
451                target,
452                line,
453                truncated,
454                SuggestionSource::Converted,
455            ));
456        }
457
458        // Convert deprecated
459        if let Some(msg) = &parsed.deprecated {
460            suggestions.push(Suggestion::deprecated(
461                target,
462                line,
463                msg,
464                SuggestionSource::Converted,
465            ));
466        }
467
468        // Convert intra-doc links to @acp:ref
469        for see_ref in &parsed.see_refs {
470            // Filter out common false positives
471            if !["self", "Self", "crate", "super"].contains(&see_ref.as_str()) {
472                suggestions.push(Suggestion::new(
473                    target,
474                    line,
475                    AnnotationType::Ref,
476                    see_ref,
477                    SuggestionSource::Converted,
478                ));
479            }
480        }
481
482        // Convert @todo to @acp:hack
483        for todo in &parsed.todos {
484            suggestions.push(Suggestion::new(
485                target,
486                line,
487                AnnotationType::Hack,
488                format!("reason=\"{}\"", todo),
489                SuggestionSource::Converted,
490            ));
491        }
492
493        // Rust-specific: unsafe code hint
494        if parsed
495            .custom_tags
496            .iter()
497            .any(|(k, v)| k == "unsafe" && v == "true")
498        {
499            suggestions.push(Suggestion::ai_hint(
500                target,
501                line,
502                "unsafe code - review safety requirements",
503                SuggestionSource::Converted,
504            ));
505        }
506
507        // Rust-specific: has safety section
508        if parsed
509            .custom_tags
510            .iter()
511            .any(|(k, v)| k == "has_safety" && v == "true")
512        {
513            suggestions.push(Suggestion::ai_hint(
514                target,
515                line,
516                "has safety requirements documented",
517                SuggestionSource::Converted,
518            ));
519        }
520
521        // Rust-specific: has panic conditions
522        if parsed
523            .custom_tags
524            .iter()
525            .any(|(k, v)| k == "has_panics" && v == "true")
526        {
527            suggestions.push(Suggestion::ai_hint(
528                target,
529                line,
530                "may panic - see Panics section",
531                SuggestionSource::Converted,
532            ));
533        }
534
535        // Rust-specific: returns Result
536        if parsed
537            .custom_tags
538            .iter()
539            .any(|(k, v)| k == "returns_result" && v == "true")
540        {
541            suggestions.push(Suggestion::ai_hint(
542                target,
543                line,
544                "returns Result type with documented errors",
545                SuggestionSource::Converted,
546            ));
547        }
548
549        // Rust-specific: module-level docs
550        if parsed
551            .custom_tags
552            .iter()
553            .any(|(k, v)| k == "module_doc" && v == "true")
554        {
555            suggestions.push(Suggestion::new(
556                target,
557                line,
558                AnnotationType::Module,
559                parsed.summary.as_deref().unwrap_or(target),
560                SuggestionSource::Converted,
561            ));
562        }
563
564        // Convert examples existence to AI hint
565        if !parsed.examples.is_empty() {
566            suggestions.push(Suggestion::ai_hint(
567                target,
568                line,
569                "has documented examples",
570                SuggestionSource::Converted,
571            ));
572        }
573
574        // Convert notes to AI hints
575        for note in &parsed.notes {
576            // Truncate long notes
577            let truncated = if note.len() > 80 {
578                format!("{}...", &note[..77])
579            } else {
580                note.clone()
581            };
582            suggestions.push(Suggestion::ai_hint(
583                target,
584                line,
585                truncated,
586                SuggestionSource::Converted,
587            ));
588        }
589
590        suggestions
591    }
592}
593
594/// @acp:summary "Truncates a string to the specified length for summary use"
595fn truncate_for_summary(s: &str, max_len: usize) -> String {
596    let trimmed = s.trim();
597    if trimmed.len() <= max_len {
598        trimmed.to_string()
599    } else {
600        let truncate_at = trimmed[..max_len].rfind(' ').unwrap_or(max_len);
601        format!("{}...", &trimmed[..truncate_at])
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_is_module_doc() {
611        assert!(RustdocParser::is_module_doc("//! Module docs"));
612        assert!(!RustdocParser::is_module_doc("/// Item docs"));
613    }
614
615    #[test]
616    fn test_strip_doc_prefix() {
617        assert_eq!(RustdocParser::strip_doc_prefix("/// Hello"), "Hello");
618        assert_eq!(RustdocParser::strip_doc_prefix("//! World"), "World");
619        assert_eq!(RustdocParser::strip_doc_prefix("///  Spaced"), "Spaced");
620    }
621
622    #[test]
623    fn test_parse_basic_rustdoc() {
624        let parser = RustdocParser::new();
625        let doc = parser.parse(
626            r#"
627/// Creates a new instance of the parser.
628///
629/// This function initializes the parser with default settings.
630"#,
631        );
632
633        assert_eq!(
634            doc.summary,
635            Some("Creates a new instance of the parser.".to_string())
636        );
637    }
638
639    #[test]
640    fn test_parse_with_arguments_section() {
641        let parser = RustdocParser::new();
642        let doc = parser.parse(
643            r#"
644/// Calculates the sum of two numbers.
645///
646/// # Arguments
647///
648/// * `a` - The first number
649/// * `b` - The second number
650///
651/// # Returns
652///
653/// The sum of a and b
654"#,
655        );
656
657        assert_eq!(
658            doc.summary,
659            Some("Calculates the sum of two numbers.".to_string())
660        );
661        assert_eq!(doc.params.len(), 2);
662        assert_eq!(doc.params[0].0, "a");
663        assert_eq!(doc.params[1].0, "b");
664        assert!(doc.returns.is_some());
665    }
666
667    #[test]
668    fn test_parse_with_panics_section() {
669        let parser = RustdocParser::new();
670        let doc = parser.parse(
671            r#"
672/// Divides two numbers.
673///
674/// # Panics
675///
676/// Panics if the divisor is zero.
677"#,
678        );
679
680        assert!(doc
681            .custom_tags
682            .iter()
683            .any(|(k, v)| k == "has_panics" && v == "true"));
684        assert!(doc.notes.iter().any(|n| n.contains("Panics")));
685    }
686
687    #[test]
688    fn test_parse_with_errors_section() {
689        let parser = RustdocParser::new();
690        let doc = parser.parse(
691            r#"
692/// Opens a file.
693///
694/// # Errors
695///
696/// Returns an error if the file does not exist.
697"#,
698        );
699
700        assert!(doc
701            .custom_tags
702            .iter()
703            .any(|(k, v)| k == "returns_result" && v == "true"));
704        assert!(doc.notes.iter().any(|n| n.contains("Errors")));
705    }
706
707    #[test]
708    fn test_parse_with_safety_section() {
709        let parser = RustdocParser::new();
710        let doc = parser.parse(
711            r#"
712/// Dereferences a raw pointer.
713///
714/// # Safety
715///
716/// The pointer must be valid and properly aligned.
717"#,
718        );
719
720        assert!(doc
721            .custom_tags
722            .iter()
723            .any(|(k, v)| k == "unsafe" && v == "true"));
724        assert!(doc
725            .custom_tags
726            .iter()
727            .any(|(k, v)| k == "has_safety" && v == "true"));
728    }
729
730    #[test]
731    fn test_parse_with_examples() {
732        let parser = RustdocParser::new();
733        let doc = parser.parse(
734            r#"
735/// Adds two numbers.
736///
737/// # Examples
738///
739/// ```rust
740/// let result = add(2, 3);
741/// assert_eq!(result, 5);
742/// ```
743"#,
744        );
745
746        assert!(!doc.examples.is_empty());
747    }
748
749    #[test]
750    fn test_parse_module_level_docs() {
751        let parser = RustdocParser::new();
752        let doc = parser.parse(
753            r#"
754//! This module provides utility functions.
755//!
756//! It includes helpers for parsing and formatting.
757"#,
758        );
759
760        assert!(doc
761            .custom_tags
762            .iter()
763            .any(|(k, v)| k == "module_doc" && v == "true"));
764        assert_eq!(
765            doc.summary,
766            Some("This module provides utility functions.".to_string())
767        );
768    }
769
770    #[test]
771    fn test_parse_intra_doc_links() {
772        let parser = RustdocParser::new();
773        let doc = parser.parse(
774            r#"
775/// See [`Parser`] and [`Config::new`] for more details.
776"#,
777        );
778
779        assert!(doc.see_refs.contains(&"Parser".to_string()));
780        assert!(doc.see_refs.contains(&"Config::new".to_string()));
781    }
782
783    #[test]
784    fn test_parse_type_parameters() {
785        let parser = RustdocParser::new();
786        let doc = parser.parse(
787            r#"
788/// A generic container.
789///
790/// # Type Parameters
791///
792/// * `T` - The type of elements stored
793/// * `E` - The error type
794"#,
795        );
796
797        assert_eq!(doc.summary, Some("A generic container.".to_string()));
798    }
799
800    #[test]
801    fn test_to_suggestions_basic() {
802        let parser = RustdocParser::new();
803        let doc = parser.parse(
804            r#"
805/// Creates a new parser instance.
806"#,
807        );
808
809        let suggestions = parser.to_suggestions(&doc, "new", 10);
810
811        assert!(suggestions
812            .iter()
813            .any(|s| s.annotation_type == AnnotationType::Summary
814                && s.value.contains("Creates a new parser")));
815    }
816
817    #[test]
818    fn test_to_suggestions_unsafe() {
819        let parser = RustdocParser::new();
820        let doc = parser.parse(
821            r#"
822/// Dereferences a raw pointer.
823///
824/// # Safety
825///
826/// The pointer must be valid.
827"#,
828        );
829
830        let suggestions = parser.to_suggestions(&doc, "deref", 1);
831
832        assert!(suggestions
833            .iter()
834            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("unsafe")));
835    }
836
837    #[test]
838    fn test_to_suggestions_panics() {
839        let parser = RustdocParser::new();
840        let doc = parser.parse(
841            r#"
842/// Divides two numbers.
843///
844/// # Panics
845///
846/// Panics if divisor is zero.
847"#,
848        );
849
850        let suggestions = parser.to_suggestions(&doc, "divide", 1);
851
852        assert!(suggestions
853            .iter()
854            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("panic")));
855    }
856
857    #[test]
858    fn test_to_suggestions_result() {
859        let parser = RustdocParser::new();
860        let doc = parser.parse(
861            r#"
862/// Opens a file.
863///
864/// # Errors
865///
866/// Returns error if file not found.
867"#,
868        );
869
870        let suggestions = parser.to_suggestions(&doc, "open_file", 1);
871
872        assert!(suggestions
873            .iter()
874            .any(|s| s.annotation_type == AnnotationType::AiHint && s.value.contains("Result")));
875    }
876
877    #[test]
878    fn test_to_suggestions_module_doc() {
879        let parser = RustdocParser::new();
880        let doc = parser.parse(
881            r#"
882//! Parser utilities module.
883"#,
884        );
885
886        let suggestions = parser.to_suggestions(&doc, "parser", 1);
887
888        assert!(suggestions
889            .iter()
890            .any(|s| s.annotation_type == AnnotationType::Module));
891    }
892
893    #[test]
894    fn test_to_suggestions_refs() {
895        let parser = RustdocParser::new();
896        let doc = parser.parse(
897            r#"
898/// See [`Config`] for configuration options.
899"#,
900        );
901
902        let suggestions = parser.to_suggestions(&doc, "func", 1);
903
904        assert!(suggestions
905            .iter()
906            .any(|s| s.annotation_type == AnnotationType::Ref && s.value == "Config"));
907    }
908
909    #[test]
910    fn test_code_block_not_parsed_as_section() {
911        let parser = RustdocParser::new();
912        let doc = parser.parse(
913            r#"
914/// Example function.
915///
916/// # Examples
917///
918/// ```rust
919/// // # This is a comment, not a section
920/// let x = 5;
921/// ```
922"#,
923        );
924
925        // The "# This is a comment" inside the code block should not create a new section
926        assert!(!doc.examples.is_empty());
927    }
928
929    #[test]
930    fn test_truncate_for_summary() {
931        assert_eq!(truncate_for_summary("Short", 100), "Short");
932        assert_eq!(
933            truncate_for_summary("This is a very long summary that needs truncation", 20),
934            "This is a very long..."
935        );
936    }
937
938    #[test]
939    fn test_see_also_section() {
940        let parser = RustdocParser::new();
941        let doc = parser.parse(
942            r#"
943/// Main function.
944///
945/// # See Also
946///
947/// - `other_function`
948/// - `related_module::helper`
949"#,
950        );
951
952        assert!(doc.see_refs.len() >= 2);
953    }
954
955    #[test]
956    fn test_multiple_sections() {
957        let parser = RustdocParser::new();
958        let doc = parser.parse(
959            r#"
960/// Processes input data.
961///
962/// # Arguments
963///
964/// * `input` - The input data
965///
966/// # Returns
967///
968/// The processed result
969///
970/// # Panics
971///
972/// Panics on invalid input
973///
974/// # Examples
975///
976/// ```
977/// let result = process("test");
978/// ```
979"#,
980        );
981
982        assert_eq!(doc.params.len(), 1);
983        assert!(doc.returns.is_some());
984        assert!(doc.custom_tags.iter().any(|(k, _)| k == "has_panics"));
985        assert!(!doc.examples.is_empty());
986    }
987}