acp/commands/
annotate.rs

1//! @acp:module "Annotate Command"
2//! @acp:summary "Analyze and suggest ACP annotations (RFC-003 provenance support)"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Implements `acp annotate` command for annotation analysis and generation.
7//! Supports RFC-0003 annotation provenance tracking.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use anyhow::Result;
14use chrono::Utc;
15use console::style;
16use rand::Rng;
17use rayon::prelude::*;
18
19use crate::annotate::{
20    Analyzer, AnnotateLevel, ConversionSource, OutputFormat, ProvenanceConfig, Suggester, Writer,
21};
22use crate::config::Config;
23use crate::git::GitRepository;
24
25/// Options for the annotate command
26#[derive(Debug, Clone)]
27pub struct AnnotateOptions {
28    /// Path to analyze
29    pub path: PathBuf,
30    /// Apply changes directly
31    pub apply: bool,
32    /// Convert from existing doc format only (no heuristics)
33    pub convert: bool,
34    /// Source format for conversion
35    pub from: ConversionSource,
36    /// Annotation detail level
37    pub level: AnnotateLevel,
38    /// Output format
39    pub format: OutputFormat,
40    /// Filter by path pattern
41    pub filter: Option<String>,
42    /// Only process file-level annotations
43    pub files_only: bool,
44    /// Only process symbol-level annotations
45    pub symbols_only: bool,
46    /// CI mode - check coverage threshold
47    pub check: bool,
48    /// Minimum coverage threshold for CI mode
49    pub min_coverage: Option<f32>,
50    /// Number of parallel workers
51    pub workers: Option<usize>,
52    /// Verbose output
53    pub verbose: bool,
54    /// RFC-0003: Disable provenance markers
55    pub no_provenance: bool,
56    /// RFC-0003: Mark all generated annotations as needing review
57    pub mark_needs_review: bool,
58}
59
60impl Default for AnnotateOptions {
61    fn default() -> Self {
62        Self {
63            path: PathBuf::from("."),
64            apply: false,
65            convert: false,
66            from: ConversionSource::Auto,
67            level: AnnotateLevel::Standard,
68            format: OutputFormat::Diff,
69            filter: None,
70            files_only: false,
71            symbols_only: false,
72            check: false,
73            min_coverage: None,
74            workers: None,
75            verbose: false,
76            no_provenance: false,
77            mark_needs_review: false,
78        }
79    }
80}
81
82/// Generate a unique generation ID for annotation batches (RFC-0003)
83///
84/// Format: `gen-YYYYMMDD-HHMMSS-XXXX` where XXXX is a random hex string
85fn generate_generation_id() -> String {
86    let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
87    let random_suffix: String = rand::rng()
88        .sample_iter(&rand::distr::Alphanumeric)
89        .take(4)
90        .map(char::from)
91        .collect();
92    format!("gen-{}-{}", timestamp, random_suffix.to_lowercase())
93}
94
95/// Execute the annotate command
96pub fn execute_annotate(options: AnnotateOptions, config: Config) -> Result<()> {
97    // Configure thread pool if workers specified
98    if let Some(num_workers) = options.workers {
99        rayon::ThreadPoolBuilder::new()
100            .num_threads(num_workers)
101            .build_global()
102            .ok(); // Ignore error if already initialized
103    }
104
105    println!(
106        "{} Analyzing codebase for annotations...",
107        style("→").cyan()
108    );
109
110    // Create analyzer and suggester
111    // When --convert is set, only use documentation conversion (no heuristics)
112    let analyzer = Arc::new(Analyzer::new(&config)?.with_level(options.level));
113    let suggester = Arc::new(
114        Suggester::new(options.level)
115            .with_conversion_source(options.from)
116            .with_heuristics(!options.convert),
117    );
118
119    // RFC-0003: Create provenance config if enabled
120    // CLI --no-provenance flag overrides config setting
121    let provenance_enabled = if options.no_provenance {
122        false
123    } else {
124        config.annotate.provenance.enabled
125    };
126
127    // CLI --mark-needs-review flag overrides config setting
128    let mark_needs_review = options.mark_needs_review || config.annotate.defaults.mark_needs_review;
129
130    let provenance_config = if provenance_enabled {
131        let generation_id = generate_generation_id();
132        if options.verbose {
133            eprintln!("Provenance generation ID: {}", generation_id);
134            eprintln!(
135                "  Review threshold: {:.0}%",
136                config.annotate.provenance.review_threshold * 100.0
137            );
138            eprintln!(
139                "  Min confidence: {:.0}%",
140                config.annotate.provenance.min_confidence * 100.0
141            );
142        }
143        Some(
144            ProvenanceConfig::new()
145                .with_generation_id(generation_id)
146                .with_needs_review(mark_needs_review)
147                .with_review_threshold(config.annotate.provenance.review_threshold as f32)
148                .with_min_confidence(config.annotate.provenance.min_confidence as f32),
149        )
150    } else {
151        None
152    };
153
154    // Create writer with optional provenance config
155    let writer = if let Some(config) = provenance_config {
156        Writer::new().with_provenance(config)
157    } else {
158        Writer::new()
159    };
160
161    // Discover files
162    let files = analyzer.discover_files(&options.path, options.filter.as_deref())?;
163
164    if options.verbose {
165        eprintln!("Found {} files to analyze", files.len());
166    }
167
168    // Warn if conversion source doesn't match detected file types
169    if options.convert && options.from != ConversionSource::Auto {
170        let mut mismatched_extensions = std::collections::HashSet::new();
171        for file_path in &files {
172            if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
173                let is_mismatch = match options.from {
174                    ConversionSource::Jsdoc | ConversionSource::Tsdoc => {
175                        !matches!(ext, "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs")
176                    }
177                    ConversionSource::Docstring => !matches!(ext, "py" | "pyi"),
178                    ConversionSource::Rustdoc => ext != "rs",
179                    ConversionSource::Godoc => ext != "go",
180                    ConversionSource::Javadoc => ext != "java",
181                    ConversionSource::Auto => false,
182                };
183                if is_mismatch {
184                    mismatched_extensions.insert(ext.to_string());
185                }
186            }
187        }
188
189        if !mismatched_extensions.is_empty() {
190            let expected = match options.from {
191                ConversionSource::Jsdoc => ".ts, .tsx, .js, .jsx",
192                ConversionSource::Tsdoc => ".ts, .tsx",
193                ConversionSource::Docstring => ".py, .pyi",
194                ConversionSource::Rustdoc => ".rs",
195                ConversionSource::Godoc => ".go",
196                ConversionSource::Javadoc => ".java",
197                ConversionSource::Auto => "any",
198            };
199            eprintln!(
200                "{} Warning: --convert {:?} is intended for {} files, but found files with extensions: {}",
201                style("⚠").yellow(),
202                options.from,
203                expected,
204                mismatched_extensions.into_iter().collect::<Vec<_>>().join(", ")
205            );
206            eprintln!(
207                "{}  Consider using --convert auto or the appropriate source for your file types",
208                style("→").cyan()
209            );
210        }
211    }
212
213    // Clone path for parallel access
214    let repo_path = options.path.clone();
215
216    // Process files in parallel
217    let results: Vec<_> = files
218        .par_iter()
219        .filter_map(|file_path| {
220            // Analyze file
221            let analysis = match analyzer.analyze_file(file_path) {
222                Ok(a) => a,
223                Err(_) => return None,
224            };
225
226            // Open git repo per-thread for thread safety
227            let git_repo = GitRepository::open(&repo_path).ok();
228
229            // Generate suggestions (with git-based heuristics if repo is available)
230            let mut suggestions = suggester.suggest_with_git(&analysis, git_repo.as_ref());
231
232            // Filter by scope
233            if options.files_only {
234                suggestions.retain(|s| s.is_file_level());
235            }
236            if options.symbols_only {
237                suggestions.retain(|s| !s.is_file_level());
238            }
239
240            // Filter by minimum confidence (from config)
241            let min_conf = config.annotate.provenance.min_confidence as f32;
242            suggestions.retain(|s| s.confidence >= min_conf);
243
244            Some((file_path.clone(), analysis, suggestions))
245        })
246        .collect();
247
248    // Aggregate results
249    let mut total_suggestions = 0;
250    let mut files_with_changes = 0;
251    let mut all_changes = Vec::new();
252    let mut all_results = Vec::new();
253
254    for (file_path, analysis, suggestions) in results {
255        all_results.push(analysis.clone());
256
257        if !suggestions.is_empty() {
258            files_with_changes += 1;
259            total_suggestions += suggestions.len();
260
261            let changes = writer.plan_changes(&file_path, &suggestions, &analysis)?;
262            all_changes.push((file_path, changes));
263        }
264    }
265
266    // Calculate statistics for output
267    let mut type_counts: HashMap<String, usize> = HashMap::new();
268    let mut source_counts: HashMap<String, usize> = HashMap::new();
269    let mut total_confidence: f32 = 0.0;
270    let mut suggestion_count: usize = 0;
271
272    for (_, changes) in &all_changes {
273        for change in changes {
274            for suggestion in &change.annotations {
275                let type_name = format!("{:?}", suggestion.annotation_type).to_lowercase();
276                *type_counts.entry(type_name).or_insert(0) += 1;
277
278                let source_name = format!("{:?}", suggestion.source);
279                *source_counts.entry(source_name).or_insert(0) += 1;
280
281                total_confidence += suggestion.confidence;
282                suggestion_count += 1;
283            }
284        }
285    }
286
287    let avg_confidence = if suggestion_count > 0 {
288        total_confidence / suggestion_count as f32
289    } else {
290        0.0
291    };
292
293    let coverage = Analyzer::calculate_total_coverage(&all_results);
294
295    // Output results
296    match options.format {
297        OutputFormat::Diff => {
298            for (file_path, changes) in &all_changes {
299                let diff = writer.generate_diff(file_path, changes)?;
300                if !diff.is_empty() {
301                    println!("{}", diff);
302                }
303            }
304        }
305        OutputFormat::Json => {
306            let output = serde_json::json!({
307                "summary": {
308                    "files_analyzed": files.len(),
309                    "files_with_suggestions": files_with_changes,
310                    "total_suggestions": total_suggestions,
311                    "coverage_percent": coverage,
312                    "average_confidence": (avg_confidence * 100.0).round() / 100.0,
313                },
314                "breakdown": {
315                    "by_type": type_counts,
316                    "by_source": source_counts,
317                },
318                "files": all_changes.iter().map(|(path, changes)| {
319                    let file_suggestions: Vec<_> = changes.iter().flat_map(|c| {
320                        c.annotations.iter().map(|s| {
321                            serde_json::json!({
322                                "target": c.symbol_name.as_deref().unwrap_or("(file)"),
323                                "line": s.line,
324                                "type": format!("{:?}", s.annotation_type).to_lowercase(),
325                                "value": s.value,
326                                "source": format!("{:?}", s.source),
327                                "confidence": (s.confidence * 100.0).round() / 100.0,
328                            })
329                        }).collect::<Vec<_>>()
330                    }).collect();
331
332                    serde_json::json!({
333                        "path": path.display().to_string(),
334                        "suggestion_count": file_suggestions.len(),
335                        "suggestions": file_suggestions,
336                    })
337                }).collect::<Vec<_>>(),
338            });
339            println!("{}", serde_json::to_string_pretty(&output)?);
340        }
341        OutputFormat::Summary => {
342            println!("\n{}", style("Annotation Summary").bold());
343            println!("==================");
344            println!("Files analyzed:          {}", files.len());
345            println!("Files with suggestions:  {}", files_with_changes);
346            println!("Total suggestions:       {}", total_suggestions);
347            println!("Current coverage:        {:.1}%", coverage);
348            println!("Avg confidence:          {:.0}%", avg_confidence * 100.0);
349
350            // Show breakdown by annotation type
351            if !type_counts.is_empty() {
352                println!("\n{}", style("By Annotation Type").bold());
353                println!("------------------");
354                let mut sorted_types: Vec<_> = type_counts.iter().collect();
355                sorted_types.sort_by(|a, b| b.1.cmp(a.1)); // Sort by count descending
356                for (type_name, count) in sorted_types {
357                    println!("  @acp:{:<14} {}", type_name, count);
358                }
359            }
360
361            // Show breakdown by source
362            if !source_counts.is_empty() && total_suggestions > 0 {
363                println!("\n{}", style("By Suggestion Source").bold());
364                println!("--------------------");
365                for (source_name, count) in &source_counts {
366                    let pct = (*count as f32 / total_suggestions as f32) * 100.0;
367                    println!("  {:<20} {} ({:.0}%)", source_name, count, pct);
368                }
369            }
370
371            if options.verbose {
372                println!("\n{}", style("File Details").bold());
373                println!("------------");
374                for (file_path, changes) in &all_changes {
375                    println!("\n{}:", file_path.display());
376                    for change in changes {
377                        let target = change.symbol_name.as_deref().unwrap_or("(file)");
378                        println!(
379                            "  - {} @ line {}: {} annotations",
380                            target,
381                            change.line,
382                            change.annotations.len()
383                        );
384                    }
385                }
386            }
387        }
388    }
389
390    // Apply changes if requested
391    if options.apply {
392        for (file_path, changes) in &all_changes {
393            writer.apply_changes(file_path, changes)?;
394            if options.verbose {
395                eprintln!("Updated: {}", file_path.display());
396            }
397        }
398        eprintln!(
399            "\n{} Applied {} suggestions to {} files",
400            style("✓").green(),
401            total_suggestions,
402            files_with_changes
403        );
404    } else if !options.check && total_suggestions > 0 {
405        eprintln!("\nRun with {} to write changes", style("--apply").cyan());
406    }
407
408    // CI mode: exit with error if coverage below threshold
409    if options.check {
410        let coverage = Analyzer::calculate_total_coverage(&all_results);
411        let threshold = options.min_coverage.unwrap_or(80.0);
412
413        if coverage < threshold {
414            eprintln!(
415                "\n{} Coverage {:.1}% is below threshold {:.1}%",
416                style("✗").red(),
417                coverage,
418                threshold
419            );
420            std::process::exit(1);
421        } else {
422            println!(
423                "\n{} Coverage {:.1}% meets threshold {:.1}%",
424                style("✓").green(),
425                coverage,
426                threshold
427            );
428        }
429    }
430
431    Ok(())
432}