acp/annotate/
mod.rs

1//! @acp:module "Annotation Generation"
2//! @acp:summary "Auto-annotation and documentation conversion for ACP adoption"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Annotation Generation
8//!
9//! This module provides functionality for:
10//! - Analyzing code to identify symbols lacking ACP annotations
11//! - Suggesting appropriate annotations based on heuristics
12//! - Converting existing documentation standards (JSDoc, docstrings, etc.) to ACP format
13//! - Applying changes via preview/dry-run or direct file modification
14//!
15//! ## Architecture
16//!
17//! The module is organized into several sub-modules:
18//! - [`analyzer`] - Code analysis and gap detection
19//! - [`suggester`] - Heuristics-based suggestion engine
20//! - [`writer`] - File modification with diff support
21//! - [`heuristics`] - Pattern-based inference rules
22//! - [`converters`] - Per-standard documentation conversion
23
24pub mod analyzer;
25pub mod converters;
26pub mod heuristics;
27pub mod suggester;
28pub mod writer;
29
30pub use analyzer::Analyzer;
31pub use converters::{DocStandardParser, ParsedDocumentation};
32pub use suggester::Suggester;
33pub use writer::{CommentStyle, Writer};
34
35use serde::{Deserialize, Serialize};
36
37use crate::ast::{SymbolKind, Visibility};
38
39/// @acp:summary "Types of ACP annotations that can be suggested"
40/// Represents the different annotation types supported by ACP.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum AnnotationType {
44    /// @acp:module - Human-readable module name
45    Module,
46    /// @acp:summary - Brief description
47    Summary,
48    /// @acp:domain - Domain classification
49    Domain,
50    /// @acp:layer - Architectural layer
51    Layer,
52    /// @acp:lock - Mutation constraint level
53    Lock,
54    /// @acp:stability - Stability indicator
55    Stability,
56    /// @acp:deprecated - Deprecation notice
57    Deprecated,
58    /// @acp:ai-hint - AI behavioral hint
59    AiHint,
60    /// @acp:ref - Reference to another symbol
61    Ref,
62    /// @acp:hack - Temporary solution marker
63    Hack,
64    /// @acp:lock-reason - Justification for lock
65    LockReason,
66}
67
68impl AnnotationType {
69    /// @acp:summary "Formats annotation type with value into ACP syntax"
70    /// Converts an annotation type and value into the proper `@acp:` format.
71    ///
72    /// # Arguments
73    /// * `value` - The annotation value to format
74    ///
75    /// # Returns
76    /// A string in the format `@acp:type value` or `@acp:type "value"`
77    pub fn to_annotation_string(&self, value: &str) -> String {
78        match self {
79            Self::Module => format!("@acp:module \"{}\"", value),
80            Self::Summary => format!("@acp:summary \"{}\"", value),
81            Self::Domain => format!("@acp:domain {}", value),
82            Self::Layer => format!("@acp:layer {}", value),
83            Self::Lock => format!("@acp:lock {}", value),
84            Self::Stability => format!("@acp:stability {}", value),
85            Self::Deprecated => format!("@acp:deprecated \"{}\"", value),
86            Self::AiHint => format!("@acp:ai-hint \"{}\"", value),
87            Self::Ref => format!("@acp:ref \"{}\"", value),
88            Self::Hack => format!("@acp:hack {}", value),
89            Self::LockReason => format!("@acp:lock-reason \"{}\"", value),
90        }
91    }
92
93    /// @acp:summary "Returns the namespace string for this annotation type"
94    pub fn namespace(&self) -> &'static str {
95        match self {
96            Self::Module => "module",
97            Self::Summary => "summary",
98            Self::Domain => "domain",
99            Self::Layer => "layer",
100            Self::Lock => "lock",
101            Self::Stability => "stability",
102            Self::Deprecated => "deprecated",
103            Self::AiHint => "ai-hint",
104            Self::Ref => "ref",
105            Self::Hack => "hack",
106            Self::LockReason => "lock-reason",
107        }
108    }
109}
110
111impl std::fmt::Display for AnnotationType {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "@acp:{}", self.namespace())
114    }
115}
116
117/// @acp:summary "Source priority for annotation suggestions"
118/// Determines the priority when merging suggestions from multiple sources.
119/// Lower ordinal value means higher priority (Explicit > Converted > Heuristic).
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum SuggestionSource {
123    /// From existing @acp: annotation in source (highest priority)
124    Explicit = 0,
125    /// From converted doc standard (JSDoc, docstring, etc.)
126    Converted = 1,
127    /// From heuristics (naming, path, visibility)
128    Heuristic = 2,
129}
130
131impl std::fmt::Display for SuggestionSource {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Self::Explicit => write!(f, "explicit"),
135            Self::Converted => write!(f, "converted"),
136            Self::Heuristic => write!(f, "heuristic"),
137        }
138    }
139}
140
141/// @acp:summary "Configuration for RFC-0003 provenance marker generation"
142#[derive(Debug, Clone)]
143pub struct ProvenanceConfig {
144    /// Generation batch ID (e.g., "gen-20251222-123456-abc")
145    pub generation_id: Option<String>,
146    /// Whether to mark all generated annotations as needing review
147    pub mark_needs_review: bool,
148    /// Confidence threshold below which annotations are flagged for review
149    pub review_threshold: f32,
150    /// Minimum confidence required to emit an annotation
151    pub min_confidence: f32,
152}
153
154impl Default for ProvenanceConfig {
155    fn default() -> Self {
156        Self {
157            generation_id: None,
158            mark_needs_review: false,
159            review_threshold: 0.8,
160            min_confidence: 0.5,
161        }
162    }
163}
164
165impl ProvenanceConfig {
166    /// @acp:summary "Creates a new provenance config"
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// @acp:summary "Sets the generation ID"
172    pub fn with_generation_id(mut self, id: impl Into<String>) -> Self {
173        self.generation_id = Some(id.into());
174        self
175    }
176
177    /// @acp:summary "Sets whether to mark annotations as needing review"
178    pub fn with_needs_review(mut self, needs_review: bool) -> Self {
179        self.mark_needs_review = needs_review;
180        self
181    }
182
183    /// @acp:summary "Sets the review threshold for low-confidence annotations"
184    pub fn with_review_threshold(mut self, threshold: f32) -> Self {
185        self.review_threshold = threshold;
186        self
187    }
188
189    /// @acp:summary "Sets the minimum confidence required to emit annotations"
190    pub fn with_min_confidence(mut self, confidence: f32) -> Self {
191        self.min_confidence = confidence;
192        self
193    }
194}
195
196/// @acp:summary "A suggested annotation to add to a symbol or file"
197/// Represents a single annotation suggestion with its metadata.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct Suggestion {
200    /// Target: file path for file-level, qualified_name for symbols
201    pub target: String,
202
203    /// Line number of the symbol (1-indexed)
204    pub line: usize,
205
206    /// Line number where annotation should be inserted (1-indexed)
207    /// This is before any decorators/attributes. Falls back to `line` if None.
208    pub insertion_line: Option<usize>,
209
210    /// The annotation type (summary, domain, lock, etc.)
211    pub annotation_type: AnnotationType,
212
213    /// The annotation value
214    pub value: String,
215
216    /// Source of this suggestion (for conflict resolution)
217    pub source: SuggestionSource,
218
219    /// Confidence score (0.0 - 1.0)
220    pub confidence: f32,
221}
222
223impl Suggestion {
224    /// @acp:summary "Creates a new suggestion"
225    pub fn new(
226        target: impl Into<String>,
227        line: usize,
228        annotation_type: AnnotationType,
229        value: impl Into<String>,
230        source: SuggestionSource,
231    ) -> Self {
232        Self {
233            target: target.into(),
234            line,
235            insertion_line: None,
236            annotation_type,
237            value: value.into(),
238            source,
239            confidence: 1.0,
240        }
241    }
242
243    /// @acp:summary "Sets the insertion line (where annotation should be placed)"
244    pub fn with_insertion_line(mut self, line: usize) -> Self {
245        self.insertion_line = Some(line);
246        self
247    }
248
249    /// @acp:summary "Gets the effective insertion line (falls back to symbol line)"
250    pub fn effective_insertion_line(&self) -> usize {
251        self.insertion_line.unwrap_or(self.line)
252    }
253
254    /// @acp:summary "Creates a summary annotation suggestion"
255    pub fn summary(
256        target: impl Into<String>,
257        line: usize,
258        value: impl Into<String>,
259        source: SuggestionSource,
260    ) -> Self {
261        Self::new(target, line, AnnotationType::Summary, value, source)
262    }
263
264    /// @acp:summary "Creates a domain annotation suggestion"
265    pub fn domain(
266        target: impl Into<String>,
267        line: usize,
268        value: impl Into<String>,
269        source: SuggestionSource,
270    ) -> Self {
271        Self::new(target, line, AnnotationType::Domain, value, source)
272    }
273
274    /// @acp:summary "Creates a lock annotation suggestion"
275    pub fn lock(
276        target: impl Into<String>,
277        line: usize,
278        value: impl Into<String>,
279        source: SuggestionSource,
280    ) -> Self {
281        Self::new(target, line, AnnotationType::Lock, value, source)
282    }
283
284    /// @acp:summary "Creates a layer annotation suggestion"
285    pub fn layer(
286        target: impl Into<String>,
287        line: usize,
288        value: impl Into<String>,
289        source: SuggestionSource,
290    ) -> Self {
291        Self::new(target, line, AnnotationType::Layer, value, source)
292    }
293
294    /// @acp:summary "Creates a module annotation suggestion"
295    pub fn module(
296        target: impl Into<String>,
297        line: usize,
298        value: impl Into<String>,
299        source: SuggestionSource,
300    ) -> Self {
301        Self::new(target, line, AnnotationType::Module, value, source)
302    }
303
304    /// @acp:summary "Creates a deprecated annotation suggestion"
305    pub fn deprecated(
306        target: impl Into<String>,
307        line: usize,
308        value: impl Into<String>,
309        source: SuggestionSource,
310    ) -> Self {
311        Self::new(target, line, AnnotationType::Deprecated, value, source)
312    }
313
314    /// @acp:summary "Creates an AI hint annotation suggestion"
315    pub fn ai_hint(
316        target: impl Into<String>,
317        line: usize,
318        value: impl Into<String>,
319        source: SuggestionSource,
320    ) -> Self {
321        Self::new(target, line, AnnotationType::AiHint, value, source)
322    }
323
324    /// @acp:summary "Sets the confidence score for this suggestion"
325    pub fn with_confidence(mut self, confidence: f32) -> Self {
326        self.confidence = confidence.clamp(0.0, 1.0);
327        self
328    }
329
330    /// @acp:summary "Returns whether this is a file-level annotation"
331    pub fn is_file_level(&self) -> bool {
332        // File-level targets are paths (contain / or \)
333        self.target.contains('/') || self.target.contains('\\')
334    }
335
336    /// @acp:summary "Formats the suggestion as an annotation string"
337    pub fn to_annotation_string(&self) -> String {
338        self.annotation_type.to_annotation_string(&self.value)
339    }
340
341    /// @acp:summary "Formats the suggestion with RFC-0003 provenance markers"
342    /// Returns multiple annotation lines: the main annotation followed by provenance markers.
343    pub fn to_annotation_strings_with_provenance(&self, config: &ProvenanceConfig) -> Vec<String> {
344        let mut lines = vec![self.annotation_type.to_annotation_string(&self.value)];
345
346        // Add source marker (convert SuggestionSource to RFC-0003 source value)
347        let source_value = match self.source {
348            SuggestionSource::Explicit => "explicit",
349            SuggestionSource::Converted => "converted",
350            SuggestionSource::Heuristic => "heuristic",
351        };
352        lines.push(format!("@acp:source {}", source_value));
353
354        // Add confidence marker (only if not 1.0)
355        if self.confidence < 1.0 {
356            lines.push(format!("@acp:source-confidence {:.2}", self.confidence));
357        }
358
359        // RFC-0003: Auto-generated annotations always need human review
360        // Only explicit (existing) annotations can be marked as reviewed
361        let reviewed = match self.source {
362            SuggestionSource::Explicit => {
363                !config.mark_needs_review && self.confidence >= config.review_threshold
364            }
365            SuggestionSource::Converted | SuggestionSource::Heuristic => false,
366        };
367        lines.push(format!("@acp:source-reviewed {}", reviewed));
368
369        // Add generation ID
370        if let Some(ref gen_id) = config.generation_id {
371            lines.push(format!("@acp:source-id \"{}\"", gen_id));
372        }
373
374        lines
375    }
376}
377
378/// @acp:summary "Result of analyzing a file for annotation gaps"
379/// Contains information about existing annotations and missing ones.
380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct AnalysisResult {
382    /// Path to the analyzed file
383    pub file_path: String,
384
385    /// Detected language
386    pub language: String,
387
388    /// Existing ACP annotations found in the file
389    pub existing_annotations: Vec<ExistingAnnotation>,
390
391    /// Symbols that need annotations
392    pub gaps: Vec<AnnotationGap>,
393
394    /// Annotation coverage percentage (0.0 - 100.0)
395    pub coverage: f32,
396}
397
398impl AnalysisResult {
399    /// @acp:summary "Creates a new analysis result"
400    pub fn new(file_path: impl Into<String>, language: impl Into<String>) -> Self {
401        Self {
402            file_path: file_path.into(),
403            language: language.into(),
404            existing_annotations: Vec::new(),
405            gaps: Vec::new(),
406            coverage: 0.0,
407        }
408    }
409
410    /// @acp:summary "Calculates the coverage percentage"
411    pub fn calculate_coverage(&mut self) {
412        let total = self.existing_annotations.len() + self.gaps.len();
413        if total == 0 {
414            self.coverage = 100.0;
415        } else {
416            self.coverage = (self.existing_annotations.len() as f32 / total as f32) * 100.0;
417        }
418    }
419}
420
421/// @acp:summary "An existing ACP annotation found in source"
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ExistingAnnotation {
424    /// Target symbol or file path
425    pub target: String,
426
427    /// The annotation type
428    pub annotation_type: AnnotationType,
429
430    /// The annotation value
431    pub value: String,
432
433    /// Line number where found (1-indexed)
434    pub line: usize,
435}
436
437/// @acp:summary "A symbol or file lacking required annotations"
438/// Represents a gap in annotation coverage.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct AnnotationGap {
441    /// Target symbol name or file path
442    pub target: String,
443
444    /// Symbol kind (None for file-level gaps)
445    pub symbol_kind: Option<SymbolKind>,
446
447    /// Line number of the symbol (1-indexed)
448    pub line: usize,
449
450    /// Line where annotation should be inserted (1-indexed)
451    /// This is before any decorators/attributes for the symbol.
452    /// Falls back to `line` if not set.
453    pub insertion_line: Option<usize>,
454
455    /// Which annotation types are missing
456    pub missing: Vec<AnnotationType>,
457
458    /// Existing doc comment (if any) that could be converted
459    pub doc_comment: Option<String>,
460
461    /// Line range of existing doc comment (start, end) - 1-indexed
462    pub doc_comment_range: Option<(usize, usize)>,
463
464    /// Whether this is exported/public
465    pub is_exported: bool,
466
467    /// Visibility of the symbol
468    pub visibility: Option<Visibility>,
469}
470
471impl AnnotationGap {
472    /// @acp:summary "Creates a new annotation gap"
473    pub fn new(target: impl Into<String>, line: usize) -> Self {
474        Self {
475            target: target.into(),
476            symbol_kind: None,
477            line,
478            insertion_line: None,
479            missing: Vec::new(),
480            doc_comment: None,
481            doc_comment_range: None,
482            is_exported: false,
483            visibility: None,
484        }
485    }
486
487    /// @acp:summary "Sets the symbol kind"
488    pub fn with_symbol_kind(mut self, kind: SymbolKind) -> Self {
489        self.symbol_kind = Some(kind);
490        self
491    }
492
493    /// @acp:summary "Sets the insertion line (where annotation should go)"
494    pub fn with_insertion_line(mut self, line: usize) -> Self {
495        self.insertion_line = Some(line);
496        self
497    }
498
499    /// @acp:summary "Gets the effective insertion line (falls back to symbol line)"
500    pub fn effective_insertion_line(&self) -> usize {
501        self.insertion_line.unwrap_or(self.line)
502    }
503
504    /// @acp:summary "Sets the doc comment"
505    pub fn with_doc_comment(mut self, doc: impl Into<String>) -> Self {
506        self.doc_comment = Some(doc.into());
507        self
508    }
509
510    /// @acp:summary "Sets the doc comment with its line range"
511    pub fn with_doc_comment_range(
512        mut self,
513        doc: impl Into<String>,
514        start: usize,
515        end: usize,
516    ) -> Self {
517        self.doc_comment = Some(doc.into());
518        self.doc_comment_range = Some((start, end));
519        self
520    }
521
522    /// @acp:summary "Marks as exported"
523    pub fn exported(mut self) -> Self {
524        self.is_exported = true;
525        self
526    }
527
528    /// @acp:summary "Sets the visibility of the symbol"
529    pub fn with_visibility(mut self, visibility: Visibility) -> Self {
530        self.visibility = Some(visibility);
531        self
532    }
533
534    /// @acp:summary "Adds a missing annotation type"
535    pub fn add_missing(&mut self, annotation_type: AnnotationType) {
536        if !self.missing.contains(&annotation_type) {
537            self.missing.push(annotation_type);
538        }
539    }
540}
541
542/// @acp:summary "Annotation level for controlling generation depth"
543/// Controls how many annotation types are generated.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
545#[serde(rename_all = "snake_case")]
546pub enum AnnotateLevel {
547    /// Only @acp:module and @acp:summary
548    Minimal,
549    /// + @acp:domain, @acp:lock, @acp:layer
550    #[default]
551    Standard,
552    /// + @acp:ref, @acp:stability, @acp:ai-hint
553    Full,
554}
555
556impl AnnotateLevel {
557    /// @acp:summary "Returns annotation types included at this level"
558    pub fn included_types(&self) -> Vec<AnnotationType> {
559        match self {
560            Self::Minimal => vec![AnnotationType::Module, AnnotationType::Summary],
561            Self::Standard => vec![
562                AnnotationType::Module,
563                AnnotationType::Summary,
564                AnnotationType::Domain,
565                AnnotationType::Lock,
566                AnnotationType::Layer,
567                AnnotationType::Deprecated,
568            ],
569            Self::Full => vec![
570                AnnotationType::Module,
571                AnnotationType::Summary,
572                AnnotationType::Domain,
573                AnnotationType::Lock,
574                AnnotationType::Layer,
575                AnnotationType::Deprecated,
576                AnnotationType::Stability,
577                AnnotationType::AiHint,
578                AnnotationType::Ref,
579                AnnotationType::Hack,
580                AnnotationType::LockReason,
581            ],
582        }
583    }
584
585    /// @acp:summary "Checks if an annotation type is included at this level"
586    pub fn includes(&self, annotation_type: AnnotationType) -> bool {
587        self.included_types().contains(&annotation_type)
588    }
589}
590
591/// @acp:summary "Source documentation standard for conversion"
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
593#[serde(rename_all = "snake_case")]
594pub enum ConversionSource {
595    /// Auto-detect based on language
596    #[default]
597    Auto,
598    /// JSDoc (JavaScript/TypeScript)
599    Jsdoc,
600    /// TSDoc (TypeScript)
601    Tsdoc,
602    /// Python docstrings (Google/NumPy/Sphinx)
603    Docstring,
604    /// Rust doc comments
605    Rustdoc,
606    /// Go documentation comments
607    Godoc,
608    /// Javadoc
609    Javadoc,
610}
611
612impl ConversionSource {
613    /// @acp:summary "Returns the appropriate conversion source for a language"
614    pub fn for_language(language: &str) -> Self {
615        match language.to_lowercase().as_str() {
616            "typescript" | "tsx" => Self::Tsdoc,
617            "javascript" | "jsx" | "js" => Self::Jsdoc,
618            "python" | "py" => Self::Docstring,
619            "rust" | "rs" => Self::Rustdoc,
620            "go" => Self::Godoc,
621            "java" => Self::Javadoc,
622            _ => Self::Auto,
623        }
624    }
625}
626
627/// @acp:summary "Output format for annotation results"
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
629#[serde(rename_all = "snake_case")]
630pub enum OutputFormat {
631    /// Unified diff format (default)
632    #[default]
633    Diff,
634    /// JSON format for tooling integration
635    Json,
636    /// Summary statistics only
637    Summary,
638}
639
640/// @acp:summary "A planned change to apply to a file"
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct FileChange {
643    /// Path to the file
644    pub file_path: String,
645
646    /// Symbol name (None for file-level)
647    pub symbol_name: Option<String>,
648
649    /// Line number where to insert (1-indexed)
650    pub line: usize,
651
652    /// Annotations to add
653    pub annotations: Vec<Suggestion>,
654
655    /// Start line of existing doc comment (if any)
656    pub existing_doc_start: Option<usize>,
657
658    /// End line of existing doc comment (if any)
659    pub existing_doc_end: Option<usize>,
660}
661
662impl FileChange {
663    /// @acp:summary "Creates a new file change"
664    pub fn new(file_path: impl Into<String>, line: usize) -> Self {
665        Self {
666            file_path: file_path.into(),
667            symbol_name: None,
668            line,
669            annotations: Vec::new(),
670            existing_doc_start: None,
671            existing_doc_end: None,
672        }
673    }
674
675    /// @acp:summary "Sets the symbol name"
676    pub fn with_symbol(mut self, name: impl Into<String>) -> Self {
677        self.symbol_name = Some(name.into());
678        self
679    }
680
681    /// @acp:summary "Sets the existing doc comment range"
682    pub fn with_existing_doc(mut self, start: usize, end: usize) -> Self {
683        self.existing_doc_start = Some(start);
684        self.existing_doc_end = Some(end);
685        self
686    }
687
688    /// @acp:summary "Adds an annotation to this change"
689    pub fn add_annotation(&mut self, suggestion: Suggestion) {
690        self.annotations.push(suggestion);
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn test_annotation_type_formatting() {
700        assert_eq!(
701            AnnotationType::Summary.to_annotation_string("Test summary"),
702            "@acp:summary \"Test summary\""
703        );
704        assert_eq!(
705            AnnotationType::Domain.to_annotation_string("authentication"),
706            "@acp:domain authentication"
707        );
708        assert_eq!(
709            AnnotationType::Lock.to_annotation_string("restricted"),
710            "@acp:lock restricted"
711        );
712    }
713
714    #[test]
715    fn test_suggestion_source_ordering() {
716        assert!(SuggestionSource::Explicit < SuggestionSource::Converted);
717        assert!(SuggestionSource::Converted < SuggestionSource::Heuristic);
718    }
719
720    #[test]
721    fn test_annotate_level_includes() {
722        assert!(AnnotateLevel::Minimal.includes(AnnotationType::Summary));
723        assert!(!AnnotateLevel::Minimal.includes(AnnotationType::Domain));
724        assert!(AnnotateLevel::Standard.includes(AnnotationType::Domain));
725        assert!(AnnotateLevel::Full.includes(AnnotationType::AiHint));
726    }
727
728    #[test]
729    fn test_suggestion_is_file_level() {
730        let file_suggestion =
731            Suggestion::summary("src/main.rs", 1, "Test", SuggestionSource::Heuristic);
732        let symbol_suggestion =
733            Suggestion::summary("MyClass", 10, "Test", SuggestionSource::Heuristic);
734
735        assert!(file_suggestion.is_file_level());
736        assert!(!symbol_suggestion.is_file_level());
737    }
738
739    #[test]
740    fn test_conversion_source_for_language() {
741        assert_eq!(
742            ConversionSource::for_language("typescript"),
743            ConversionSource::Tsdoc
744        );
745        assert_eq!(
746            ConversionSource::for_language("python"),
747            ConversionSource::Docstring
748        );
749        assert_eq!(
750            ConversionSource::for_language("rust"),
751            ConversionSource::Rustdoc
752        );
753        assert_eq!(
754            ConversionSource::for_language("unknown"),
755            ConversionSource::Auto
756        );
757    }
758}