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 where annotation should be inserted (1-indexed)
204    pub line: usize,
205
206    /// The annotation type (summary, domain, lock, etc.)
207    pub annotation_type: AnnotationType,
208
209    /// The annotation value
210    pub value: String,
211
212    /// Source of this suggestion (for conflict resolution)
213    pub source: SuggestionSource,
214
215    /// Confidence score (0.0 - 1.0)
216    pub confidence: f32,
217}
218
219impl Suggestion {
220    /// @acp:summary "Creates a new suggestion"
221    pub fn new(
222        target: impl Into<String>,
223        line: usize,
224        annotation_type: AnnotationType,
225        value: impl Into<String>,
226        source: SuggestionSource,
227    ) -> Self {
228        Self {
229            target: target.into(),
230            line,
231            annotation_type,
232            value: value.into(),
233            source,
234            confidence: 1.0,
235        }
236    }
237
238    /// @acp:summary "Creates a summary annotation suggestion"
239    pub fn summary(
240        target: impl Into<String>,
241        line: usize,
242        value: impl Into<String>,
243        source: SuggestionSource,
244    ) -> Self {
245        Self::new(target, line, AnnotationType::Summary, value, source)
246    }
247
248    /// @acp:summary "Creates a domain annotation suggestion"
249    pub fn domain(
250        target: impl Into<String>,
251        line: usize,
252        value: impl Into<String>,
253        source: SuggestionSource,
254    ) -> Self {
255        Self::new(target, line, AnnotationType::Domain, value, source)
256    }
257
258    /// @acp:summary "Creates a lock annotation suggestion"
259    pub fn lock(
260        target: impl Into<String>,
261        line: usize,
262        value: impl Into<String>,
263        source: SuggestionSource,
264    ) -> Self {
265        Self::new(target, line, AnnotationType::Lock, value, source)
266    }
267
268    /// @acp:summary "Creates a layer annotation suggestion"
269    pub fn layer(
270        target: impl Into<String>,
271        line: usize,
272        value: impl Into<String>,
273        source: SuggestionSource,
274    ) -> Self {
275        Self::new(target, line, AnnotationType::Layer, value, source)
276    }
277
278    /// @acp:summary "Creates a deprecated annotation suggestion"
279    pub fn deprecated(
280        target: impl Into<String>,
281        line: usize,
282        value: impl Into<String>,
283        source: SuggestionSource,
284    ) -> Self {
285        Self::new(target, line, AnnotationType::Deprecated, value, source)
286    }
287
288    /// @acp:summary "Creates an AI hint annotation suggestion"
289    pub fn ai_hint(
290        target: impl Into<String>,
291        line: usize,
292        value: impl Into<String>,
293        source: SuggestionSource,
294    ) -> Self {
295        Self::new(target, line, AnnotationType::AiHint, value, source)
296    }
297
298    /// @acp:summary "Sets the confidence score for this suggestion"
299    pub fn with_confidence(mut self, confidence: f32) -> Self {
300        self.confidence = confidence.clamp(0.0, 1.0);
301        self
302    }
303
304    /// @acp:summary "Returns whether this is a file-level annotation"
305    pub fn is_file_level(&self) -> bool {
306        // File-level targets are paths (contain / or \)
307        self.target.contains('/') || self.target.contains('\\')
308    }
309
310    /// @acp:summary "Formats the suggestion as an annotation string"
311    pub fn to_annotation_string(&self) -> String {
312        self.annotation_type.to_annotation_string(&self.value)
313    }
314
315    /// @acp:summary "Formats the suggestion with RFC-0003 provenance markers"
316    /// Returns multiple annotation lines: the main annotation followed by provenance markers.
317    pub fn to_annotation_strings_with_provenance(&self, config: &ProvenanceConfig) -> Vec<String> {
318        let mut lines = vec![self.annotation_type.to_annotation_string(&self.value)];
319
320        // Add source marker (convert SuggestionSource to RFC-0003 source value)
321        let source_value = match self.source {
322            SuggestionSource::Explicit => "explicit",
323            SuggestionSource::Converted => "converted",
324            SuggestionSource::Heuristic => "heuristic",
325        };
326        lines.push(format!("@acp:source {}", source_value));
327
328        // Add confidence marker (only if not 1.0)
329        if self.confidence < 1.0 {
330            lines.push(format!("@acp:source-confidence {:.2}", self.confidence));
331        }
332
333        // Add reviewed marker: false if explicitly marked for review OR below confidence threshold
334        let reviewed = !config.mark_needs_review && self.confidence >= config.review_threshold;
335        lines.push(format!("@acp:source-reviewed {}", reviewed));
336
337        // Add generation ID
338        if let Some(ref gen_id) = config.generation_id {
339            lines.push(format!("@acp:source-id \"{}\"", gen_id));
340        }
341
342        lines
343    }
344}
345
346/// @acp:summary "Result of analyzing a file for annotation gaps"
347/// Contains information about existing annotations and missing ones.
348#[derive(Debug, Clone, Default, Serialize, Deserialize)]
349pub struct AnalysisResult {
350    /// Path to the analyzed file
351    pub file_path: String,
352
353    /// Detected language
354    pub language: String,
355
356    /// Existing ACP annotations found in the file
357    pub existing_annotations: Vec<ExistingAnnotation>,
358
359    /// Symbols that need annotations
360    pub gaps: Vec<AnnotationGap>,
361
362    /// Annotation coverage percentage (0.0 - 100.0)
363    pub coverage: f32,
364}
365
366impl AnalysisResult {
367    /// @acp:summary "Creates a new analysis result"
368    pub fn new(file_path: impl Into<String>, language: impl Into<String>) -> Self {
369        Self {
370            file_path: file_path.into(),
371            language: language.into(),
372            existing_annotations: Vec::new(),
373            gaps: Vec::new(),
374            coverage: 0.0,
375        }
376    }
377
378    /// @acp:summary "Calculates the coverage percentage"
379    pub fn calculate_coverage(&mut self) {
380        let total = self.existing_annotations.len() + self.gaps.len();
381        if total == 0 {
382            self.coverage = 100.0;
383        } else {
384            self.coverage = (self.existing_annotations.len() as f32 / total as f32) * 100.0;
385        }
386    }
387}
388
389/// @acp:summary "An existing ACP annotation found in source"
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ExistingAnnotation {
392    /// Target symbol or file path
393    pub target: String,
394
395    /// The annotation type
396    pub annotation_type: AnnotationType,
397
398    /// The annotation value
399    pub value: String,
400
401    /// Line number where found (1-indexed)
402    pub line: usize,
403}
404
405/// @acp:summary "A symbol or file lacking required annotations"
406/// Represents a gap in annotation coverage.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct AnnotationGap {
409    /// Target symbol name or file path
410    pub target: String,
411
412    /// Symbol kind (None for file-level gaps)
413    pub symbol_kind: Option<SymbolKind>,
414
415    /// Line number of the symbol (1-indexed)
416    pub line: usize,
417
418    /// Which annotation types are missing
419    pub missing: Vec<AnnotationType>,
420
421    /// Existing doc comment (if any) that could be converted
422    pub doc_comment: Option<String>,
423
424    /// Line range of existing doc comment (start, end) - 1-indexed
425    pub doc_comment_range: Option<(usize, usize)>,
426
427    /// Whether this is exported/public
428    pub is_exported: bool,
429
430    /// Visibility of the symbol
431    pub visibility: Option<Visibility>,
432}
433
434impl AnnotationGap {
435    /// @acp:summary "Creates a new annotation gap"
436    pub fn new(target: impl Into<String>, line: usize) -> Self {
437        Self {
438            target: target.into(),
439            symbol_kind: None,
440            line,
441            missing: Vec::new(),
442            doc_comment: None,
443            doc_comment_range: None,
444            is_exported: false,
445            visibility: None,
446        }
447    }
448
449    /// @acp:summary "Sets the symbol kind"
450    pub fn with_symbol_kind(mut self, kind: SymbolKind) -> Self {
451        self.symbol_kind = Some(kind);
452        self
453    }
454
455    /// @acp:summary "Sets the doc comment"
456    pub fn with_doc_comment(mut self, doc: impl Into<String>) -> Self {
457        self.doc_comment = Some(doc.into());
458        self
459    }
460
461    /// @acp:summary "Sets the doc comment with its line range"
462    pub fn with_doc_comment_range(
463        mut self,
464        doc: impl Into<String>,
465        start: usize,
466        end: usize,
467    ) -> Self {
468        self.doc_comment = Some(doc.into());
469        self.doc_comment_range = Some((start, end));
470        self
471    }
472
473    /// @acp:summary "Marks as exported"
474    pub fn exported(mut self) -> Self {
475        self.is_exported = true;
476        self
477    }
478
479    /// @acp:summary "Sets the visibility of the symbol"
480    pub fn with_visibility(mut self, visibility: Visibility) -> Self {
481        self.visibility = Some(visibility);
482        self
483    }
484
485    /// @acp:summary "Adds a missing annotation type"
486    pub fn add_missing(&mut self, annotation_type: AnnotationType) {
487        if !self.missing.contains(&annotation_type) {
488            self.missing.push(annotation_type);
489        }
490    }
491}
492
493/// @acp:summary "Annotation level for controlling generation depth"
494/// Controls how many annotation types are generated.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum AnnotateLevel {
498    /// Only @acp:module and @acp:summary
499    Minimal,
500    /// + @acp:domain, @acp:lock, @acp:layer
501    #[default]
502    Standard,
503    /// + @acp:ref, @acp:stability, @acp:ai-hint
504    Full,
505}
506
507impl AnnotateLevel {
508    /// @acp:summary "Returns annotation types included at this level"
509    pub fn included_types(&self) -> Vec<AnnotationType> {
510        match self {
511            Self::Minimal => vec![AnnotationType::Module, AnnotationType::Summary],
512            Self::Standard => vec![
513                AnnotationType::Module,
514                AnnotationType::Summary,
515                AnnotationType::Domain,
516                AnnotationType::Lock,
517                AnnotationType::Layer,
518                AnnotationType::Deprecated,
519            ],
520            Self::Full => vec![
521                AnnotationType::Module,
522                AnnotationType::Summary,
523                AnnotationType::Domain,
524                AnnotationType::Lock,
525                AnnotationType::Layer,
526                AnnotationType::Deprecated,
527                AnnotationType::Stability,
528                AnnotationType::AiHint,
529                AnnotationType::Ref,
530                AnnotationType::Hack,
531                AnnotationType::LockReason,
532            ],
533        }
534    }
535
536    /// @acp:summary "Checks if an annotation type is included at this level"
537    pub fn includes(&self, annotation_type: AnnotationType) -> bool {
538        self.included_types().contains(&annotation_type)
539    }
540}
541
542/// @acp:summary "Source documentation standard for conversion"
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub enum ConversionSource {
546    /// Auto-detect based on language
547    #[default]
548    Auto,
549    /// JSDoc (JavaScript/TypeScript)
550    Jsdoc,
551    /// TSDoc (TypeScript)
552    Tsdoc,
553    /// Python docstrings (Google/NumPy/Sphinx)
554    Docstring,
555    /// Rust doc comments
556    Rustdoc,
557    /// Go documentation comments
558    Godoc,
559    /// Javadoc
560    Javadoc,
561}
562
563impl ConversionSource {
564    /// @acp:summary "Returns the appropriate conversion source for a language"
565    pub fn for_language(language: &str) -> Self {
566        match language.to_lowercase().as_str() {
567            "typescript" | "tsx" => Self::Tsdoc,
568            "javascript" | "jsx" | "js" => Self::Jsdoc,
569            "python" | "py" => Self::Docstring,
570            "rust" | "rs" => Self::Rustdoc,
571            "go" => Self::Godoc,
572            "java" => Self::Javadoc,
573            _ => Self::Auto,
574        }
575    }
576}
577
578/// @acp:summary "Output format for annotation results"
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
580#[serde(rename_all = "snake_case")]
581pub enum OutputFormat {
582    /// Unified diff format (default)
583    #[default]
584    Diff,
585    /// JSON format for tooling integration
586    Json,
587    /// Summary statistics only
588    Summary,
589}
590
591/// @acp:summary "A planned change to apply to a file"
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct FileChange {
594    /// Path to the file
595    pub file_path: String,
596
597    /// Symbol name (None for file-level)
598    pub symbol_name: Option<String>,
599
600    /// Line number where to insert (1-indexed)
601    pub line: usize,
602
603    /// Annotations to add
604    pub annotations: Vec<Suggestion>,
605
606    /// Start line of existing doc comment (if any)
607    pub existing_doc_start: Option<usize>,
608
609    /// End line of existing doc comment (if any)
610    pub existing_doc_end: Option<usize>,
611}
612
613impl FileChange {
614    /// @acp:summary "Creates a new file change"
615    pub fn new(file_path: impl Into<String>, line: usize) -> Self {
616        Self {
617            file_path: file_path.into(),
618            symbol_name: None,
619            line,
620            annotations: Vec::new(),
621            existing_doc_start: None,
622            existing_doc_end: None,
623        }
624    }
625
626    /// @acp:summary "Sets the symbol name"
627    pub fn with_symbol(mut self, name: impl Into<String>) -> Self {
628        self.symbol_name = Some(name.into());
629        self
630    }
631
632    /// @acp:summary "Sets the existing doc comment range"
633    pub fn with_existing_doc(mut self, start: usize, end: usize) -> Self {
634        self.existing_doc_start = Some(start);
635        self.existing_doc_end = Some(end);
636        self
637    }
638
639    /// @acp:summary "Adds an annotation to this change"
640    pub fn add_annotation(&mut self, suggestion: Suggestion) {
641        self.annotations.push(suggestion);
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn test_annotation_type_formatting() {
651        assert_eq!(
652            AnnotationType::Summary.to_annotation_string("Test summary"),
653            "@acp:summary \"Test summary\""
654        );
655        assert_eq!(
656            AnnotationType::Domain.to_annotation_string("authentication"),
657            "@acp:domain authentication"
658        );
659        assert_eq!(
660            AnnotationType::Lock.to_annotation_string("restricted"),
661            "@acp:lock restricted"
662        );
663    }
664
665    #[test]
666    fn test_suggestion_source_ordering() {
667        assert!(SuggestionSource::Explicit < SuggestionSource::Converted);
668        assert!(SuggestionSource::Converted < SuggestionSource::Heuristic);
669    }
670
671    #[test]
672    fn test_annotate_level_includes() {
673        assert!(AnnotateLevel::Minimal.includes(AnnotationType::Summary));
674        assert!(!AnnotateLevel::Minimal.includes(AnnotationType::Domain));
675        assert!(AnnotateLevel::Standard.includes(AnnotationType::Domain));
676        assert!(AnnotateLevel::Full.includes(AnnotationType::AiHint));
677    }
678
679    #[test]
680    fn test_suggestion_is_file_level() {
681        let file_suggestion =
682            Suggestion::summary("src/main.rs", 1, "Test", SuggestionSource::Heuristic);
683        let symbol_suggestion =
684            Suggestion::summary("MyClass", 10, "Test", SuggestionSource::Heuristic);
685
686        assert!(file_suggestion.is_file_level());
687        assert!(!symbol_suggestion.is_file_level());
688    }
689
690    #[test]
691    fn test_conversion_source_for_language() {
692        assert_eq!(
693            ConversionSource::for_language("typescript"),
694            ConversionSource::Tsdoc
695        );
696        assert_eq!(
697            ConversionSource::for_language("python"),
698            ConversionSource::Docstring
699        );
700        assert_eq!(
701            ConversionSource::for_language("rust"),
702            ConversionSource::Rustdoc
703        );
704        assert_eq!(
705            ConversionSource::for_language("unknown"),
706            ConversionSource::Auto
707        );
708    }
709}