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    // Clone path for parallel access
169    let repo_path = options.path.clone();
170
171    // Process files in parallel
172    let results: Vec<_> = files
173        .par_iter()
174        .filter_map(|file_path| {
175            // Analyze file
176            let analysis = match analyzer.analyze_file(file_path) {
177                Ok(a) => a,
178                Err(_) => return None,
179            };
180
181            // Open git repo per-thread for thread safety
182            let git_repo = GitRepository::open(&repo_path).ok();
183
184            // Generate suggestions (with git-based heuristics if repo is available)
185            let mut suggestions = suggester.suggest_with_git(&analysis, git_repo.as_ref());
186
187            // Filter by scope
188            if options.files_only {
189                suggestions.retain(|s| s.is_file_level());
190            }
191            if options.symbols_only {
192                suggestions.retain(|s| !s.is_file_level());
193            }
194
195            // Filter by minimum confidence (from config)
196            let min_conf = config.annotate.provenance.min_confidence as f32;
197            suggestions.retain(|s| s.confidence >= min_conf);
198
199            Some((file_path.clone(), analysis, suggestions))
200        })
201        .collect();
202
203    // Aggregate results
204    let mut total_suggestions = 0;
205    let mut files_with_changes = 0;
206    let mut all_changes = Vec::new();
207    let mut all_results = Vec::new();
208
209    for (file_path, analysis, suggestions) in results {
210        all_results.push(analysis.clone());
211
212        if !suggestions.is_empty() {
213            files_with_changes += 1;
214            total_suggestions += suggestions.len();
215
216            let changes = writer.plan_changes(&file_path, &suggestions, &analysis)?;
217            all_changes.push((file_path, changes));
218        }
219    }
220
221    // Calculate statistics for output
222    let mut type_counts: HashMap<String, usize> = HashMap::new();
223    let mut source_counts: HashMap<String, usize> = HashMap::new();
224    let mut total_confidence: f32 = 0.0;
225    let mut suggestion_count: usize = 0;
226
227    for (_, changes) in &all_changes {
228        for change in changes {
229            for suggestion in &change.annotations {
230                let type_name = format!("{:?}", suggestion.annotation_type).to_lowercase();
231                *type_counts.entry(type_name).or_insert(0) += 1;
232
233                let source_name = format!("{:?}", suggestion.source);
234                *source_counts.entry(source_name).or_insert(0) += 1;
235
236                total_confidence += suggestion.confidence;
237                suggestion_count += 1;
238            }
239        }
240    }
241
242    let avg_confidence = if suggestion_count > 0 {
243        total_confidence / suggestion_count as f32
244    } else {
245        0.0
246    };
247
248    let coverage = Analyzer::calculate_total_coverage(&all_results);
249
250    // Output results
251    match options.format {
252        OutputFormat::Diff => {
253            for (file_path, changes) in &all_changes {
254                let diff = writer.generate_diff(file_path, changes)?;
255                if !diff.is_empty() {
256                    println!("{}", diff);
257                }
258            }
259        }
260        OutputFormat::Json => {
261            let output = serde_json::json!({
262                "summary": {
263                    "files_analyzed": files.len(),
264                    "files_with_suggestions": files_with_changes,
265                    "total_suggestions": total_suggestions,
266                    "coverage_percent": coverage,
267                    "average_confidence": (avg_confidence * 100.0).round() / 100.0,
268                },
269                "breakdown": {
270                    "by_type": type_counts,
271                    "by_source": source_counts,
272                },
273                "files": all_changes.iter().map(|(path, changes)| {
274                    let file_suggestions: Vec<_> = changes.iter().flat_map(|c| {
275                        c.annotations.iter().map(|s| {
276                            serde_json::json!({
277                                "target": c.symbol_name.as_deref().unwrap_or("(file)"),
278                                "line": s.line,
279                                "type": format!("{:?}", s.annotation_type).to_lowercase(),
280                                "value": s.value,
281                                "source": format!("{:?}", s.source),
282                                "confidence": (s.confidence * 100.0).round() / 100.0,
283                            })
284                        }).collect::<Vec<_>>()
285                    }).collect();
286
287                    serde_json::json!({
288                        "path": path.display().to_string(),
289                        "suggestion_count": file_suggestions.len(),
290                        "suggestions": file_suggestions,
291                    })
292                }).collect::<Vec<_>>(),
293            });
294            println!("{}", serde_json::to_string_pretty(&output)?);
295        }
296        OutputFormat::Summary => {
297            println!("\n{}", style("Annotation Summary").bold());
298            println!("==================");
299            println!("Files analyzed:          {}", files.len());
300            println!("Files with suggestions:  {}", files_with_changes);
301            println!("Total suggestions:       {}", total_suggestions);
302            println!("Current coverage:        {:.1}%", coverage);
303            println!("Avg confidence:          {:.0}%", avg_confidence * 100.0);
304
305            // Show breakdown by annotation type
306            if !type_counts.is_empty() {
307                println!("\n{}", style("By Annotation Type").bold());
308                println!("------------------");
309                let mut sorted_types: Vec<_> = type_counts.iter().collect();
310                sorted_types.sort_by(|a, b| b.1.cmp(a.1)); // Sort by count descending
311                for (type_name, count) in sorted_types {
312                    println!("  @acp:{:<14} {}", type_name, count);
313                }
314            }
315
316            // Show breakdown by source
317            if !source_counts.is_empty() && total_suggestions > 0 {
318                println!("\n{}", style("By Suggestion Source").bold());
319                println!("--------------------");
320                for (source_name, count) in &source_counts {
321                    let pct = (*count as f32 / total_suggestions as f32) * 100.0;
322                    println!("  {:<20} {} ({:.0}%)", source_name, count, pct);
323                }
324            }
325
326            if options.verbose {
327                println!("\n{}", style("File Details").bold());
328                println!("------------");
329                for (file_path, changes) in &all_changes {
330                    println!("\n{}:", file_path.display());
331                    for change in changes {
332                        let target = change.symbol_name.as_deref().unwrap_or("(file)");
333                        println!(
334                            "  - {} @ line {}: {} annotations",
335                            target,
336                            change.line,
337                            change.annotations.len()
338                        );
339                    }
340                }
341            }
342        }
343    }
344
345    // Apply changes if requested
346    if options.apply {
347        for (file_path, changes) in &all_changes {
348            writer.apply_changes(file_path, changes)?;
349            if options.verbose {
350                eprintln!("Updated: {}", file_path.display());
351            }
352        }
353        eprintln!(
354            "\n{} Applied {} suggestions to {} files",
355            style("✓").green(),
356            total_suggestions,
357            files_with_changes
358        );
359    } else if !options.check && total_suggestions > 0 {
360        eprintln!("\nRun with {} to write changes", style("--apply").cyan());
361    }
362
363    // CI mode: exit with error if coverage below threshold
364    if options.check {
365        let coverage = Analyzer::calculate_total_coverage(&all_results);
366        let threshold = options.min_coverage.unwrap_or(80.0);
367
368        if coverage < threshold {
369            eprintln!(
370                "\n{} Coverage {:.1}% is below threshold {:.1}%",
371                style("✗").red(),
372                coverage,
373                threshold
374            );
375            std::process::exit(1);
376        } else {
377            println!(
378                "\n{} Coverage {:.1}% meets threshold {:.1}%",
379                style("✓").green(),
380                coverage,
381                threshold
382            );
383        }
384    }
385
386    Ok(())
387}