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 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 let repo_path = options.path.clone();
215
216 let results: Vec<_> = files
218 .par_iter()
219 .filter_map(|file_path| {
220 let analysis = match analyzer.analyze_file(file_path) {
222 Ok(a) => a,
223 Err(_) => return None,
224 };
225
226 let git_repo = GitRepository::open(&repo_path).ok();
228
229 let mut suggestions = suggester.suggest_with_git(&analysis, git_repo.as_ref());
231
232 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 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 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 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 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 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)); for (type_name, count) in sorted_types {
357 println!(" @acp:{:<14} {}", type_name, count);
358 }
359 }
360
361 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 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 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}