1use std::io::{self, Write};
12use std::path::PathBuf;
13
14use anyhow::Result;
15use chrono::Utc;
16use console::style;
17
18use crate::cache::{AnnotationProvenance, Cache};
19use crate::commands::query::ConfidenceFilter;
20use crate::parse::SourceOrigin;
21
22#[derive(Debug, Clone)]
24pub struct ReviewOptions {
25 pub cache: PathBuf,
27 pub source: Option<SourceOrigin>,
29 pub confidence: Option<String>,
31 pub json: bool,
33}
34
35impl Default for ReviewOptions {
36 fn default() -> Self {
37 Self {
38 cache: PathBuf::from(".acp/acp.cache.json"),
39 source: None,
40 confidence: None,
41 json: false,
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub enum ReviewSubcommand {
49 List,
51 Mark {
53 file: Option<PathBuf>,
54 symbol: Option<String>,
55 all: bool,
56 },
57 Interactive,
59}
60
61#[derive(Debug, Clone)]
63struct ReviewItem {
64 target: String,
65 annotation: String,
66 value: String,
67 source: SourceOrigin,
68 confidence: Option<f64>,
69}
70
71pub fn execute_review(options: ReviewOptions, subcommand: ReviewSubcommand) -> Result<()> {
73 match subcommand {
74 ReviewSubcommand::List => {
75 let cache = Cache::from_json(&options.cache)?;
76 list_for_review(&cache, &options)
77 }
78 ReviewSubcommand::Mark { file, symbol, all } => {
79 let mut cache = Cache::from_json(&options.cache)?;
80 mark_reviewed(&mut cache, &options, file.as_ref(), symbol.as_deref(), all)?;
81 cache.write_json(&options.cache)?;
82 Ok(())
83 }
84 ReviewSubcommand::Interactive => {
85 let mut cache = Cache::from_json(&options.cache)?;
86 interactive_review(&mut cache, &options)?;
87 cache.write_json(&options.cache)?;
88 Ok(())
89 }
90 }
91}
92
93fn list_for_review(cache: &Cache, options: &ReviewOptions) -> Result<()> {
95 let items = collect_review_items(cache, options);
96
97 if items.is_empty() {
98 println!("{} No annotations need review!", style("✓").green());
99 return Ok(());
100 }
101
102 if options.json {
103 let json_items: Vec<_> = items
104 .iter()
105 .map(|item| {
106 serde_json::json!({
107 "target": item.target,
108 "annotation": item.annotation,
109 "value": item.value,
110 "source": format!("{:?}", item.source).to_lowercase(),
111 "confidence": item.confidence,
112 })
113 })
114 .collect();
115 println!("{}", serde_json::to_string_pretty(&json_items)?);
116 return Ok(());
117 }
118
119 println!(
120 "{} {} annotations need review:",
121 style("→").cyan(),
122 items.len()
123 );
124 println!();
125
126 for (i, item) in items.iter().enumerate() {
127 println!("{}. {}", i + 1, style(&item.target).cyan());
128 println!(
129 " @acp:{} \"{}\"",
130 item.annotation,
131 truncate_value(&item.value, 50)
132 );
133 println!(" Source: {:?}", item.source);
134 if let Some(conf) = item.confidence {
135 println!(" Confidence: {:.2}", conf);
136 }
137 println!();
138 }
139
140 println!(
141 "Run {} to mark all as reviewed",
142 style("acp review mark --all").cyan()
143 );
144
145 Ok(())
146}
147
148fn collect_review_items(cache: &Cache, options: &ReviewOptions) -> Vec<ReviewItem> {
150 let mut items = Vec::new();
151 let conf_filter = options
152 .confidence
153 .as_ref()
154 .and_then(|c| ConfidenceFilter::parse(c).ok());
155
156 for (path, file) in &cache.files {
158 for (key, prov) in &file.annotations {
159 if should_include(prov, options, &conf_filter) {
160 items.push(ReviewItem {
161 target: path.clone(),
162 annotation: key.trim_start_matches("@acp:").to_string(),
163 value: prov.value.clone(),
164 source: prov.source,
165 confidence: prov.confidence,
166 });
167 }
168 }
169 }
170
171 for symbol in cache.symbols.values() {
173 for (key, prov) in &symbol.annotations {
174 if should_include(prov, options, &conf_filter) {
175 items.push(ReviewItem {
176 target: format!("{}:{}", symbol.file, symbol.name),
177 annotation: key.trim_start_matches("@acp:").to_string(),
178 value: prov.value.clone(),
179 source: prov.source,
180 confidence: prov.confidence,
181 });
182 }
183 }
184 }
185
186 items.sort_by(|a, b| {
188 a.confidence
189 .unwrap_or(1.0)
190 .partial_cmp(&b.confidence.unwrap_or(1.0))
191 .unwrap_or(std::cmp::Ordering::Equal)
192 });
193
194 items
195}
196
197fn should_include(
199 prov: &AnnotationProvenance,
200 options: &ReviewOptions,
201 conf_filter: &Option<ConfidenceFilter>,
202) -> bool {
203 if prov.reviewed || !prov.needs_review {
205 return false;
206 }
207
208 if let Some(ref source) = options.source {
210 if prov.source != *source {
211 return false;
212 }
213 }
214
215 if let Some(ref filter) = conf_filter {
217 if let Some(conf) = prov.confidence {
218 if !filter.matches(conf) {
219 return false;
220 }
221 }
222 }
223
224 true
225}
226
227fn mark_reviewed(
229 cache: &mut Cache,
230 options: &ReviewOptions,
231 file: Option<&PathBuf>,
232 symbol: Option<&str>,
233 all: bool,
234) -> Result<()> {
235 let now = Utc::now().to_rfc3339();
236 let conf_filter = options
237 .confidence
238 .as_ref()
239 .and_then(|c| ConfidenceFilter::parse(c).ok());
240 let mut count = 0;
241
242 for (path, file_entry) in cache.files.iter_mut() {
244 if let Some(filter_path) = file {
246 if !path.contains(&filter_path.to_string_lossy().to_string()) {
247 continue;
248 }
249 }
250
251 for prov in file_entry.annotations.values_mut() {
252 if (all || should_include(prov, options, &conf_filter)) && !prov.reviewed {
253 prov.reviewed = true;
254 prov.needs_review = false;
255 prov.reviewed_at = Some(now.clone());
256 count += 1;
257 }
258 }
259 }
260
261 for sym in cache.symbols.values_mut() {
263 if let Some(sym_filter) = symbol {
265 if sym.name != sym_filter {
266 continue;
267 }
268 }
269
270 if let Some(filter_path) = file {
272 if !sym
273 .file
274 .contains(&filter_path.to_string_lossy().to_string())
275 {
276 continue;
277 }
278 }
279
280 for prov in sym.annotations.values_mut() {
281 if (all || should_include(prov, options, &conf_filter)) && !prov.reviewed {
282 prov.reviewed = true;
283 prov.needs_review = false;
284 prov.reviewed_at = Some(now.clone());
285 count += 1;
286 }
287 }
288 }
289
290 recompute_provenance_stats(cache);
292
293 println!(
294 "{} Marked {} annotations as reviewed",
295 style("✓").green(),
296 count
297 );
298
299 Ok(())
300}
301
302fn interactive_review(cache: &mut Cache, options: &ReviewOptions) -> Result<()> {
304 let items = collect_review_items(cache, options);
305
306 if items.is_empty() {
307 println!("{} No annotations need review!", style("✓").green());
308 return Ok(());
309 }
310
311 println!("{}", style("Interactive Review Mode").bold());
312 println!("{}", "=".repeat(40));
313 println!("{} annotations to review", items.len());
314 println!();
315 println!("Commands: [a]ccept, [s]kip, [q]uit");
316 println!();
317
318 let now = Utc::now().to_rfc3339();
319 let mut reviewed_count = 0;
320 let mut skipped_count = 0;
321
322 for item in &items {
323 println!("{}", style(&item.target).cyan());
324 println!(
325 " @acp:{} \"{}\"",
326 item.annotation,
327 truncate_value(&item.value, 50)
328 );
329 println!(" Source: {:?}", item.source);
330 if let Some(conf) = item.confidence {
331 println!(" Confidence: {:.2}", conf);
332 }
333 println!();
334
335 print!("{} ", style(">").yellow());
336 io::stdout().flush()?;
337
338 let mut input = String::new();
339 io::stdin().read_line(&mut input)?;
340
341 match input.trim().chars().next() {
342 Some('a') | Some('A') => {
343 mark_single_reviewed(cache, item, &now)?;
345 println!("{} Marked as reviewed", style("✓").green());
346 reviewed_count += 1;
347 }
348 Some('s') | Some('S') => {
349 println!("Skipped");
350 skipped_count += 1;
351 }
352 Some('q') | Some('Q') => {
353 println!("\nExiting review");
354 break;
355 }
356 _ => {
357 println!("Unknown command, skipping");
358 skipped_count += 1;
359 }
360 }
361
362 println!();
363 }
364
365 recompute_provenance_stats(cache);
367
368 println!("{}", "=".repeat(40));
369 println!(
370 "Reviewed: {}, Skipped: {}",
371 style(reviewed_count).green(),
372 skipped_count
373 );
374
375 Ok(())
376}
377
378fn mark_single_reviewed(cache: &mut Cache, item: &ReviewItem, timestamp: &str) -> Result<()> {
380 let key = format!("@acp:{}", item.annotation);
381
382 if item.target.contains(':') {
384 let parts: Vec<&str> = item.target.rsplitn(2, ':').collect();
386 if parts.len() == 2 {
387 let sym_name = parts[0];
388 if let Some(sym) = cache.symbols.get_mut(sym_name) {
389 if let Some(prov) = sym.annotations.get_mut(&key) {
390 prov.reviewed = true;
391 prov.needs_review = false;
392 prov.reviewed_at = Some(timestamp.to_string());
393 }
394 }
395 }
396 } else {
397 if let Some(file) = cache.files.get_mut(&item.target) {
399 if let Some(prov) = file.annotations.get_mut(&key) {
400 prov.reviewed = true;
401 prov.needs_review = false;
402 prov.reviewed_at = Some(timestamp.to_string());
403 }
404 }
405 }
406
407 Ok(())
408}
409
410fn recompute_provenance_stats(cache: &mut Cache) {
412 let mut total = 0u64;
413 let mut needs_review = 0u64;
414 let mut reviewed = 0u64;
415 let mut explicit = 0u64;
416 let mut converted = 0u64;
417 let mut heuristic = 0u64;
418 let mut refined = 0u64;
419 let mut inferred = 0u64;
420
421 for file in cache.files.values() {
423 for prov in file.annotations.values() {
424 total += 1;
425 if prov.needs_review {
426 needs_review += 1;
427 }
428 if prov.reviewed {
429 reviewed += 1;
430 }
431 match prov.source {
432 SourceOrigin::Explicit => explicit += 1,
433 SourceOrigin::Converted => converted += 1,
434 SourceOrigin::Heuristic => heuristic += 1,
435 SourceOrigin::Refined => refined += 1,
436 SourceOrigin::Inferred => inferred += 1,
437 }
438 }
439 }
440
441 for sym in cache.symbols.values() {
443 for prov in sym.annotations.values() {
444 total += 1;
445 if prov.needs_review {
446 needs_review += 1;
447 }
448 if prov.reviewed {
449 reviewed += 1;
450 }
451 match prov.source {
452 SourceOrigin::Explicit => explicit += 1,
453 SourceOrigin::Converted => converted += 1,
454 SourceOrigin::Heuristic => heuristic += 1,
455 SourceOrigin::Refined => refined += 1,
456 SourceOrigin::Inferred => inferred += 1,
457 }
458 }
459 }
460
461 cache.provenance.summary.total = total;
463 cache.provenance.summary.needs_review = needs_review;
464 cache.provenance.summary.reviewed = reviewed;
465 cache.provenance.summary.by_source.explicit = explicit;
466 cache.provenance.summary.by_source.converted = converted;
467 cache.provenance.summary.by_source.heuristic = heuristic;
468 cache.provenance.summary.by_source.refined = refined;
469 cache.provenance.summary.by_source.inferred = inferred;
470}
471
472fn truncate_value(s: &str, max_len: usize) -> String {
474 if s.len() <= max_len {
475 s.to_string()
476 } else {
477 format!("{}...", &s[..max_len - 3])
478 }
479}