acp/commands/
review.rs

1//! @acp:module "Review Command"
2//! @acp:summary "Review and manage annotation provenance (RFC-0003)"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Provides functionality for reviewing auto-generated annotations:
7//! - List annotations needing review
8//! - Mark annotations as reviewed
9//! - Interactive review mode
10
11use 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/// Options for the review command (RFC-0003)
23#[derive(Debug, Clone)]
24pub struct ReviewOptions {
25    /// Cache file path
26    pub cache: PathBuf,
27    /// Filter by source origin
28    pub source: Option<SourceOrigin>,
29    /// Filter by confidence expression (e.g., "<0.7", ">=0.9")
30    pub confidence: Option<String>,
31    /// Output as JSON
32    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/// Review subcommands
47#[derive(Debug, Clone)]
48pub enum ReviewSubcommand {
49    /// List annotations needing review
50    List,
51    /// Mark annotations as reviewed
52    Mark {
53        file: Option<PathBuf>,
54        symbol: Option<String>,
55        all: bool,
56    },
57    /// Interactive review mode
58    Interactive,
59}
60
61/// Item for review display
62#[derive(Debug, Clone)]
63struct ReviewItem {
64    target: String,
65    annotation: String,
66    value: String,
67    source: SourceOrigin,
68    confidence: Option<f64>,
69}
70
71/// Execute the review command
72pub 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
93/// List all annotations needing review
94fn 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
148/// Collect all items that need review
149fn 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    // Collect from files
157    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    // Collect from symbols
172    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    // Sort by confidence (lowest first)
187    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
197/// Check if an annotation should be included based on filters
198fn should_include(
199    prov: &AnnotationProvenance,
200    options: &ReviewOptions,
201    conf_filter: &Option<ConfidenceFilter>,
202) -> bool {
203    // Must need review and not already reviewed
204    if prov.reviewed || !prov.needs_review {
205        return false;
206    }
207
208    // Source filter
209    if let Some(ref source) = options.source {
210        if prov.source != *source {
211            return false;
212        }
213    }
214
215    // Confidence filter
216    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
227/// Mark annotations as reviewed
228fn 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    // Mark file annotations
243    for (path, file_entry) in cache.files.iter_mut() {
244        // Check file filter
245        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    // Mark symbol annotations
262    for sym in cache.symbols.values_mut() {
263        // Check symbol filter
264        if let Some(sym_filter) = symbol {
265            if sym.name != sym_filter {
266                continue;
267            }
268        }
269
270        // Check file filter for symbol
271        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
291    recompute_provenance_stats(cache);
292
293    println!(
294        "{} Marked {} annotations as reviewed",
295        style("✓").green(),
296        count
297    );
298
299    Ok(())
300}
301
302/// Interactive review mode
303fn 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 as reviewed in cache
344                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 stats
366    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
378/// Mark a single annotation as reviewed
379fn mark_single_reviewed(cache: &mut Cache, item: &ReviewItem, timestamp: &str) -> Result<()> {
380    let key = format!("@acp:{}", item.annotation);
381
382    // Check if target is a file or symbol (symbol format: file:name)
383    if item.target.contains(':') {
384        // Symbol (format: file:name)
385        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        // File
398        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
410/// Recompute provenance statistics after modifications
411fn 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    // Count file annotations
422    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    // Count symbol annotations
442    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    // Update stats
462    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
472/// Truncate a string value for display
473fn 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}