1use 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#[derive(Debug, Clone)]
27pub struct AnnotateOptions {
28 pub path: PathBuf,
30 pub apply: bool,
32 pub convert: bool,
34 pub from: ConversionSource,
36 pub level: AnnotateLevel,
38 pub format: OutputFormat,
40 pub filter: Option<String>,
42 pub files_only: bool,
44 pub symbols_only: bool,
46 pub check: bool,
48 pub min_coverage: Option<f32>,
50 pub workers: Option<usize>,
52 pub verbose: bool,
54 pub no_provenance: bool,
56 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
82fn 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
95pub fn execute_annotate(options: AnnotateOptions, config: Config) -> Result<()> {
97 if let Some(num_workers) = options.workers {
99 rayon::ThreadPoolBuilder::new()
100 .num_threads(num_workers)
101 .build_global()
102 .ok(); }
104
105 println!(
106 "{} Analyzing codebase for annotations...",
107 style("→").cyan()
108 );
109
110 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 let provenance_enabled = if options.no_provenance {
122 false
123 } else {
124 config.annotate.provenance.enabled
125 };
126
127 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 let writer = if let Some(config) = provenance_config {
156 Writer::new().with_provenance(config)
157 } else {
158 Writer::new()
159 };
160
161 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 let repo_path = options.path.clone();
170
171 let results: Vec<_> = files
173 .par_iter()
174 .filter_map(|file_path| {
175 let analysis = match analyzer.analyze_file(file_path) {
177 Ok(a) => a,
178 Err(_) => return None,
179 };
180
181 let git_repo = GitRepository::open(&repo_path).ok();
183
184 let mut suggestions = suggester.suggest_with_git(&analysis, git_repo.as_ref());
186
187 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 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 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 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 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 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)); for (type_name, count) in sorted_types {
312 println!(" @acp:{:<14} {}", type_name, count);
313 }
314 }
315
316 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 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 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}