Skip to main content

seshat_cli/tui/
app.rs

1use std::hash::{DefaultHasher, Hash, Hasher};
2use std::sync::{Arc, Mutex};
3
4use rusqlite::params;
5use seshat_core::{BranchId, NodeId};
6use seshat_graph::{SQL_NOT_REMOVED, compute_description_hash, lock_conn};
7use seshat_storage::{
8    Decision, DecisionNature, DecisionRepository, DecisionState, DecisionWeight, ExampleEvidence,
9    SqliteDecisionRepository,
10};
11
12use crate::error::CliError;
13
14#[derive(Debug, Clone)]
15pub struct ConventionItem {
16    pub node_id: i64,
17    pub description: String,
18    pub nature: String,
19    pub weight: String,
20    pub confidence_pct: u32,
21    pub adoption_count: u32,
22    pub total_count: u32,
23    pub adoption_rate_pct: u32,
24    pub trend: String,
25    pub source: String,
26    pub examples: Vec<CodeExample>,
27    /// SHA256-style snapshot hash of ext_data at query time.
28    /// Used for optimistic concurrency check on reject.
29    pub snapshot_hash: u64,
30    /// Index into `examples` vector for left/right cycling.
31    pub example_index: usize,
32    /// SHA256 hash of normalized description for deduplication.
33    pub description_hash: Option<String>,
34}
35
36#[derive(Debug, Clone)]
37pub struct CodeExample {
38    pub file: String,
39    pub line: u32,
40    pub end_line: u32,
41    pub snippet: String,
42    pub snippet_start_line: u32,
43}
44
45#[derive(Debug, Clone)]
46pub enum ReviewAction {
47    Confirm {
48        node_id: i64,
49        description: String,
50        examples: Vec<CodeExample>,
51    },
52    Reject {
53        node_id: i64,
54        snapshot_hash: u64,
55    },
56    Partial {
57        node_id: i64,
58        description: String,
59        original_node_id: i64,
60    },
61    Skip {
62        node_id: i64,
63    },
64}
65
66pub struct App {
67    pub conventions: Vec<ConventionItem>,
68    pub current_index: usize,
69    pub results: Vec<ReviewAction>,
70    pub quit: bool,
71    pub saving: bool,
72    pub review_complete: bool,
73    /// Tracks which convention indices have already been acted on (y/n/p/s).
74    acted_on: Vec<bool>,
75    pub search_mode: bool,
76    pub search_query: String,
77    pub filter_locked: bool,
78    pub filtered_indices: Vec<usize>,
79}
80
81impl App {
82    pub fn new(conventions: Vec<ConventionItem>) -> Self {
83        let len = conventions.len();
84        let filtered: Vec<usize> = (0..len).collect();
85        Self {
86            conventions,
87            current_index: 0,
88            results: Vec::new(),
89            quit: false,
90            saving: false,
91            review_complete: false,
92            acted_on: vec![false; len],
93            search_mode: false,
94            search_query: String::new(),
95            filter_locked: false,
96            filtered_indices: filtered,
97        }
98    }
99
100    pub fn filtered_current_index(&self) -> usize {
101        self.filtered_indices
102            .iter()
103            .position(|&i| i == self.current_index)
104            .unwrap_or(0)
105    }
106
107    pub fn filtered_total(&self) -> usize {
108        self.filtered_indices.len()
109    }
110
111    pub fn filtered_current(&self) -> Option<&ConventionItem> {
112        self.current()
113    }
114
115    pub fn filtered_next(&mut self) {
116        if let Some(pos) = self
117            .filtered_indices
118            .iter()
119            .position(|&i| i == self.current_index)
120        {
121            if pos + 1 < self.filtered_indices.len() {
122                self.current_index = self.filtered_indices[pos + 1];
123            }
124        }
125    }
126
127    pub fn filtered_previous(&mut self) {
128        if let Some(pos) = self
129            .filtered_indices
130            .iter()
131            .position(|&i| i == self.current_index)
132        {
133            if pos > 0 {
134                self.current_index = self.filtered_indices[pos - 1];
135            }
136        }
137    }
138
139    fn rebuild_filtered_indices(&mut self) {
140        let query = self.search_query.to_lowercase();
141        let previous = self.current_index;
142        self.filtered_indices = (0..self.conventions.len())
143            .filter(|&i| {
144                self.conventions
145                    .get(i)
146                    .map(|c| c.description.to_lowercase())
147                    .map(|desc| fuzzy_match(&query, &desc))
148                    .unwrap_or(false)
149            })
150            .collect();
151
152        if self.filtered_indices.contains(&previous) {
153            return;
154        }
155        if let Some(first_match) = self.filtered_indices.first().copied() {
156            self.current_index = first_match;
157        }
158    }
159
160    pub fn push_search_char(&mut self, ch: char) {
161        self.search_query.push(ch);
162        self.rebuild_filtered_indices();
163    }
164
165    pub fn pop_search_char(&mut self) {
166        self.search_query.pop();
167        if self.search_query.is_empty() {
168            self.cancel_search();
169        } else {
170            self.rebuild_filtered_indices();
171        }
172    }
173
174    pub fn lock_filter(&mut self) {
175        if self.filtered_indices.is_empty() {
176            return;
177        }
178        self.filter_locked = true;
179        self.search_mode = false;
180    }
181
182    pub fn cancel_search(&mut self) {
183        self.search_query.clear();
184        self.search_mode = false;
185        self.filter_locked = false;
186        self.filtered_indices = (0..self.conventions.len()).collect();
187        if !self.filtered_indices.is_empty() {
188            self.current_index = self.filtered_indices[0];
189        }
190    }
191
192    pub fn mark_acted_on(&mut self, index: usize) {
193        if index < self.acted_on.len() {
194            self.acted_on[index] = true;
195        }
196    }
197
198    pub fn is_acted_on(&self, index: usize) -> bool {
199        self.acted_on.get(index).copied().unwrap_or(true)
200    }
201
202    pub fn all_acted_on(&self) -> bool {
203        self.acted_on.iter().all(|&b| b)
204    }
205
206    /// Advance to the next un-reviewed convention.
207    /// Searches forward from current position, then wraps to the start.
208    /// If all conventions have been reviewed, sets `quit = true`.
209    pub fn advance_to_next_unreviewed(&mut self) {
210        let total = self.conventions.len();
211        if total == 0 {
212            self.quit = true;
213            return;
214        }
215
216        for offset in 1..=total {
217            let idx = (self.current_index + offset) % total;
218            if !self.acted_on[idx] {
219                self.current_index = idx;
220                if let Some(conv) = self.conventions.get_mut(self.current_index) {
221                    conv.example_index = 0;
222                }
223                self.review_complete = false;
224                return;
225            }
226        }
227
228        self.quit = true;
229    }
230
231    pub fn current(&self) -> Option<&ConventionItem> {
232        self.conventions.get(self.current_index)
233    }
234
235    pub fn example_total(&self) -> usize {
236        self.current().map(|c| c.examples.len()).unwrap_or(0)
237    }
238
239    pub fn next_example(&mut self) {
240        let total = self.example_total();
241        if total <= 1 {
242            return;
243        }
244        if let Some(c) = self.current() {
245            let idx = c.example_index;
246            let new_idx = (idx + 1) % total;
247            if let Some(conv) = self.conventions.get_mut(self.current_index) {
248                conv.example_index = new_idx;
249            }
250        }
251    }
252
253    pub fn previous_example(&mut self) {
254        let total = self.example_total();
255        if total <= 1 {
256            return;
257        }
258        if let Some(c) = self.current() {
259            let idx = c.example_index;
260            let new_idx = if idx == 0 { total - 1 } else { idx - 1 };
261            if let Some(conv) = self.conventions.get_mut(self.current_index) {
262                conv.example_index = new_idx;
263            }
264        }
265    }
266
267    pub fn next(&mut self) {
268        if self.current_index < self.conventions.len().saturating_sub(1) {
269            self.current_index += 1;
270            if let Some(conv) = self.conventions.get_mut(self.current_index) {
271                conv.example_index = 0;
272            }
273        }
274        self.review_complete = self.current_index >= self.conventions.len().saturating_sub(1);
275    }
276
277    pub fn previous(&mut self) {
278        if self.current_index > 0 {
279            self.current_index -= 1;
280            if let Some(conv) = self.conventions.get_mut(self.current_index) {
281                conv.example_index = 0;
282            }
283        }
284        self.review_complete = self.current_index >= self.conventions.len().saturating_sub(1);
285    }
286
287    pub fn total(&self) -> usize {
288        self.conventions.len()
289    }
290}
291
292fn compute_snapshot_hash(ext_data: &Option<String>) -> u64 {
293    let mut hasher = DefaultHasher::default();
294    ext_data.as_deref().unwrap_or("").hash(&mut hasher);
295    hasher.finish()
296}
297
298pub fn query_conventions_for_review(
299    conn: &Arc<Mutex<rusqlite::Connection>>,
300    branch_id: &str,
301) -> Result<(Vec<ConventionItem>, String), CliError> {
302    let guard = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
303
304    // Auto-detected conventions live in `nodes`. User decisions live in the
305    // project-wide `decisions` table keyed by `description_hash`. A LEFT JOIN
306    // (post-US-006) hides any node whose description_hash matches a decision
307    // row in ANY state — approved/rejected/partial/recorded all suppress the
308    // re-prompt. `d.description_hash IS NULL` is the load-bearing predicate
309    // (V12 has no `id` column; PK is `description_hash`).
310    let sql = format!(
311        "SELECT n.id, n.description, n.nature, n.weight, n.confidence,
312                n.adoption_count, n.total_count, n.ext_data, n.description_hash
313         FROM nodes n
314         LEFT JOIN decisions d ON d.description_hash = n.description_hash
315         WHERE n.branch_id = ?1
316           AND n.nature IN ('convention', 'observation')
317           AND {sql_not_removed}
318           AND d.description_hash IS NULL
319         ORDER BY n.confidence DESC",
320        sql_not_removed = SQL_NOT_REMOVED
321    );
322
323    let mut stmt = guard
324        .prepare(&sql)
325        .map_err(|e| CliError::TuiError(e.to_string()))?;
326
327    let rows = stmt
328        .query_map(params![branch_id], |row| {
329            let id: i64 = row.get(0)?;
330            let description: String = row.get(1)?;
331            let nature: String = row.get(2)?;
332            let weight: String = row.get(3)?;
333            let confidence: f64 = row.get(4)?;
334            let adoption_count: u32 = row.get(5)?;
335            let total_count: u32 = row.get(6)?;
336            let ext_data: Option<String> = row.get(7)?;
337            let description_hash: Option<String> = row.get(8)?;
338            Ok((
339                id,
340                description,
341                nature,
342                weight,
343                confidence,
344                adoption_count,
345                total_count,
346                ext_data,
347                description_hash,
348            ))
349        })
350        .map_err(|e| CliError::TuiError(e.to_string()))?;
351
352    let mut conventions = Vec::new();
353
354    for row_result in rows {
355        let (
356            id,
357            description,
358            nature,
359            weight,
360            confidence,
361            adoption_count,
362            total_count,
363            ext_data,
364            description_hash,
365        ) = row_result.map_err(|e| CliError::TuiError(e.to_string()))?;
366
367        let ext: Option<serde_json::Value> = ext_data
368            .as_deref()
369            .and_then(|s| serde_json::from_str(s).ok());
370
371        let source = ext
372            .as_ref()
373            .and_then(|e| e.get("source"))
374            .and_then(|v| v.as_str())
375            .unwrap_or("auto_detected")
376            .to_owned();
377        let trend = ext
378            .as_ref()
379            .and_then(|e| e.get("trend"))
380            .and_then(|v| v.as_str())
381            .unwrap_or("unknown")
382            .to_owned();
383        let examples = parse_evidence(&ext);
384
385        conventions.push(ConventionItem {
386            node_id: id,
387            description,
388            nature,
389            weight,
390            confidence_pct: (confidence.clamp(0.0, 1.0) * 100.0).round() as u32,
391            adoption_count,
392            total_count,
393            adoption_rate_pct: if total_count > 0 {
394                ((adoption_count as f64 / total_count as f64) * 100.0).round() as u32
395            } else {
396                0
397            },
398            trend,
399            source: source.clone(),
400            examples,
401            snapshot_hash: compute_snapshot_hash(&ext_data),
402            description_hash,
403            example_index: 0,
404        });
405    }
406
407    Ok((conventions, branch_id.to_string()))
408}
409
410/// Count project-wide decisions that count toward "this convention is settled
411/// knowledge": approved, partial, and recorded states. Rejections are excluded
412/// — they're decisions, but they don't represent confirmed conventions.
413/// Project-wide (no branch filter): a decision approved on any branch counts
414/// once.
415pub fn count_confirmed_conventions(conn: &Arc<Mutex<rusqlite::Connection>>) -> usize {
416    let guard = match lock_conn(conn) {
417        Ok(g) => g,
418        Err(e) => {
419            tracing::warn!("failed to lock connection for count_confirmed_conventions: {e}");
420            return 0;
421        }
422    };
423    guard
424        .query_row(
425            "SELECT COUNT(*) FROM decisions \
426             WHERE state IN ('approved', 'partial', 'recorded')",
427            [],
428            |row| row.get::<_, i64>(0),
429        )
430        .unwrap_or(0) as usize
431}
432
433fn parse_evidence(ext: &Option<serde_json::Value>) -> Vec<CodeExample> {
434    let evidence = match ext
435        .as_ref()
436        .and_then(|e| e.get("evidence"))
437        .and_then(|v| v.as_array())
438    {
439        Some(arr) => arr,
440        None => return Vec::new(),
441    };
442
443    let mut examples = Vec::new();
444    for item in evidence {
445        let file = item
446            .get("file")
447            .and_then(|v| v.as_str())
448            .unwrap_or("")
449            .to_owned();
450        let line = item.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
451        let end_line = item
452            .get("end_line")
453            .and_then(|v| v.as_u64())
454            .unwrap_or(line as u64) as u32;
455        let snippet = item
456            .get("snippet")
457            .and_then(|v| {
458                v.get("content")
459                    .and_then(|c| c.as_str())
460                    .or_else(|| v.as_str())
461            })
462            .unwrap_or("")
463            .to_owned();
464        let snippet_start_line = item
465            .get("snippet_start_line")
466            .and_then(|v| v.as_u64())
467            .unwrap_or(0) as u32;
468        // Empty `file` is a valid composite/synthetic evidence row
469        // (e.g. the file-level composite produced by aggregate_findings
470        // for "98 files match this convention" summaries). Skip only
471        // when both file and snippet are empty — those carry no info.
472        if file.is_empty() && snippet.is_empty() {
473            continue;
474        }
475        examples.push(CodeExample {
476            file,
477            line,
478            end_line,
479            snippet,
480            snippet_start_line,
481        });
482    }
483    examples
484}
485
486pub fn apply_review_actions(
487    conn: &Arc<Mutex<rusqlite::Connection>>,
488    branch_id: &str,
489    results: &[ReviewAction],
490) -> Result<(), CliError> {
491    if results.is_empty() {
492        return Ok(());
493    }
494
495    {
496        let guard = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
497        guard
498            .execute_batch("BEGIN IMMEDIATE")
499            .map_err(|e| CliError::TuiError(format!("BEGIN transaction: {e}")))?;
500    }
501
502    // P28: each action runs inside its own SAVEPOINT. A single Reject
503    // is itself a 3-step pipeline (upsert decision → soft-delete node →
504    // delete FTS entry); pre-fix a failure between steps could leave the
505    // first step committed at the end of the outer transaction while the
506    // remaining steps were silently dropped. Savepointing per-action
507    // gives all-or-nothing semantics for each ReviewAction without
508    // dragging unrelated successful actions down with a single failure.
509    let mut fail_count = 0usize;
510    for (idx, action) in results.iter().enumerate() {
511        let sp = format!("review_action_{idx}");
512        {
513            let g = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
514            g.execute_batch(&format!("SAVEPOINT {sp}"))
515                .map_err(|e| CliError::TuiError(format!("SAVEPOINT {sp}: {e}")))?;
516        }
517        let result = match action {
518            ReviewAction::Confirm {
519                description,
520                examples,
521                ..
522            } => confirm_convention(conn, branch_id, description, examples),
523            ReviewAction::Reject {
524                node_id,
525                snapshot_hash,
526            } => reject_convention(conn, *node_id, branch_id, *snapshot_hash),
527            ReviewAction::Partial { description, .. } => {
528                partial_convention(conn, branch_id, description)
529            }
530            ReviewAction::Skip { .. } => Ok(()),
531        };
532        let g = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
533        match result {
534            Ok(()) => {
535                g.execute_batch(&format!("RELEASE {sp}"))
536                    .map_err(|e| CliError::TuiError(format!("RELEASE {sp}: {e}")))?;
537            }
538            Err(e) => {
539                tracing::warn!(node_id = ?action.node_id_if_reject(), "action skipped: {e}");
540                // Rollback only this action's partial writes. Other
541                // actions that already RELEASEd remain pending in the
542                // outer transaction.
543                let _ = g.execute_batch(&format!("ROLLBACK TO {sp}"));
544                let _ = g.execute_batch(&format!("RELEASE {sp}"));
545                fail_count += 1;
546            }
547        }
548    }
549
550    if fail_count > 0 && fail_count == results.len() {
551        let g = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
552        let _ = g.execute_batch("ROLLBACK");
553        return Err(CliError::TuiError(
554            "all review actions failed; no changes applied. \
555             Run `seshat review` again to retry."
556                .to_owned(),
557        ));
558    }
559
560    seshat_graph::rebuild_fts_index(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
561
562    {
563        let g = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
564        g.execute_batch("COMMIT")
565            .map_err(|e| CliError::TuiError(format!("COMMIT transaction: {e}")))?;
566    }
567
568    if fail_count > 0 {
569        tracing::info!(
570            fail_count,
571            success_count = results.len() - fail_count,
572            "some actions skipped, rest committed"
573        );
574    }
575
576    Ok(())
577}
578
579trait ReviewActionDebug {
580    fn node_id_if_reject(&self) -> Option<i64>;
581}
582
583impl ReviewActionDebug for ReviewAction {
584    fn node_id_if_reject(&self) -> Option<i64> {
585        match self {
586            ReviewAction::Confirm { node_id, .. }
587            | ReviewAction::Reject { node_id, .. }
588            | ReviewAction::Partial { node_id, .. }
589            | ReviewAction::Skip { node_id } => Some(*node_id),
590        }
591    }
592}
593
594fn examples_to_evidence(examples: &[CodeExample]) -> Vec<ExampleEvidence> {
595    examples
596        .iter()
597        .map(|e| ExampleEvidence {
598            file: e.file.clone(),
599            line: e.line,
600            end_line: e.end_line,
601            snippet: e.snippet.clone(),
602        })
603        .collect()
604}
605
606fn upsert_decision(
607    conn: &Arc<Mutex<rusqlite::Connection>>,
608    decision: Decision,
609) -> Result<(), CliError> {
610    let repo = SqliteDecisionRepository::new(conn.clone());
611    repo.upsert(&decision)
612        .map_err(|e| CliError::TuiError(e.to_string()))
613}
614
615fn confirm_convention(
616    conn: &Arc<Mutex<rusqlite::Connection>>,
617    branch_id: &str,
618    description: &str,
619    examples: &[CodeExample],
620) -> Result<(), CliError> {
621    let now = chrono::Utc::now().timestamp();
622    let decision = Decision {
623        description_hash: compute_description_hash(description),
624        description: description.to_owned(),
625        state: DecisionState::Approved,
626        nature: DecisionNature::Convention,
627        weight: DecisionWeight::Strong,
628        category: None,
629        reason: Some("Confirmed via seshat review TUI".to_owned()),
630        examples: examples_to_evidence(examples),
631        decided_on_branch: BranchId(branch_id.to_owned()),
632        decided_at: now,
633        updated_at: now,
634    };
635    upsert_decision(conn, decision)
636}
637
638fn reject_convention(
639    conn: &Arc<Mutex<rusqlite::Connection>>,
640    node_id: i64,
641    branch_id: &str,
642    expected_hash: u64,
643) -> Result<(), CliError> {
644    // Read description + ext_data of the auto-detected node we're rejecting.
645    // The optimistic concurrency check operates on the ext_data snapshot;
646    // the user-decided row is keyed by description_hash so collisions on
647    // the decisions side are not possible.
648    let (description, ext_data): (String, Option<String>) = {
649        let guard = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
650        guard
651            .query_row(
652                "SELECT description, ext_data FROM nodes WHERE id = ?1",
653                params![node_id],
654                |row| Ok((row.get(0)?, row.get(1)?)),
655            )
656            .map_err(|e| CliError::TuiError(e.to_string()))?
657    };
658
659    let current_hash = compute_snapshot_hash(&ext_data);
660    if current_hash != expected_hash {
661        return Err(CliError::TuiError(format!(
662            "convention {node_id} was modified during review; please retry"
663        )));
664    }
665
666    let now = chrono::Utc::now().timestamp();
667    let decision = Decision {
668        description_hash: compute_description_hash(&description),
669        description: description.clone(),
670        state: DecisionState::Rejected,
671        nature: DecisionNature::Convention,
672        weight: DecisionWeight::Strong,
673        category: None,
674        reason: Some("Rejected via seshat review TUI".to_owned()),
675        examples: Vec::new(),
676        decided_on_branch: BranchId(branch_id.to_owned()),
677        decided_at: now,
678        updated_at: now,
679    };
680    upsert_decision(conn, decision)?;
681
682    // Cosmetic: soft-delete the auto-detected node so it disappears from
683    // review queues and FTS until the next scan hard-deletes it. Persisting
684    // the rejection lives in `decisions`, so this is purely for cleaner
685    // snapshot output between scans.
686    let mut ext: serde_json::Value = ext_data
687        .as_deref()
688        .and_then(|s| serde_json::from_str(s).ok())
689        .unwrap_or(serde_json::json!({}));
690    ext["removed"] = serde_json::json!(1);
691    ext["removed_reason"] = serde_json::json!("Rejected via seshat review TUI");
692    ext["removed_at"] = serde_json::json!(now);
693
694    {
695        let guard = lock_conn(conn).map_err(|e| CliError::TuiError(e.to_string()))?;
696        guard
697            .execute(
698                "UPDATE nodes SET ext_data = ?1 WHERE id = ?2",
699                params![ext.to_string(), node_id],
700            )
701            .map_err(|e| CliError::TuiError(e.to_string()))?;
702    }
703    seshat_graph::delete_fts_entry(conn, NodeId(node_id))
704        .map_err(|e| CliError::TuiError(e.to_string()))?;
705
706    Ok(())
707}
708
709fn partial_convention(
710    conn: &Arc<Mutex<rusqlite::Connection>>,
711    branch_id: &str,
712    description: &str,
713) -> Result<(), CliError> {
714    let now = chrono::Utc::now().timestamp();
715    let decision = Decision {
716        description_hash: compute_description_hash(description),
717        description: description.to_owned(),
718        state: DecisionState::Partial,
719        nature: DecisionNature::Preference,
720        weight: DecisionWeight::Strong,
721        category: None,
722        reason: Some("Partially confirmed via seshat review TUI".to_owned()),
723        examples: Vec::new(),
724        decided_on_branch: BranchId(branch_id.to_owned()),
725        decided_at: now,
726        updated_at: now,
727    };
728    upsert_decision(conn, decision)
729}
730
731pub struct SummaryContext {
732    /// Total conventions in the scope returned by the query (excludes already-confirmed and rejected).
733    pub total_in_scope: usize,
734    /// Project-wide count of decisions already settled before this session.
735    /// Counts all rows in `decisions` with state in (approved, partial, recorded);
736    /// not branch-filtered (V12 schema makes decisions project-wide).
737    pub already_confirmed: usize,
738}
739
740/// Display a rich summary with full session context: totals, per-session counts,
741/// session precision, and overall coverage including already-confirmed from DB.
742///
743/// When the user presses q immediately: all session counts are 0, pending = total_in_scope,
744/// precision = 0%, coverage = already_confirmed / (total_in_scope + already_confirmed) * 100.
745pub fn show_summary(results: &[ReviewAction], context: &SummaryContext) {
746    let confirmed = results
747        .iter()
748        .filter(|r| matches!(r, ReviewAction::Confirm { .. }))
749        .count();
750    let rejected = results
751        .iter()
752        .filter(|r| matches!(r, ReviewAction::Reject { .. }))
753        .count();
754    let partial = results
755        .iter()
756        .filter(|r| matches!(r, ReviewAction::Partial { .. }))
757        .count();
758    let skipped = results
759        .iter()
760        .filter(|r| matches!(r, ReviewAction::Skip { .. }))
761        .count();
762
763    let total_decided = confirmed.saturating_add(rejected).saturating_add(partial);
764
765    let still_pending = context
766        .total_in_scope
767        .saturating_sub(total_decided)
768        .saturating_sub(skipped);
769
770    let precision_denom = total_decided.max(1);
771    let session_precision = (confirmed as f64 / precision_denom as f64 * 100.0).round() as u32;
772
773    let total_with_db = context
774        .total_in_scope
775        .saturating_add(context.already_confirmed);
776    let overall_coverage = if total_with_db > 0 {
777        let val = (context.already_confirmed.saturating_add(confirmed)) as f64
778            / total_with_db as f64
779            * 100.0;
780        val.round() as u32
781    } else {
782        0
783    };
784
785    println!("\n   -- Review Complete ----------------------------------------------------------");
786    println!(
787        "   {:<24}  {:>4}",
788        "Conventions in scope:", context.total_in_scope
789    );
790    println!(
791        "   {:<24}  {:>4}",
792        "Already decided (project):", context.already_confirmed
793    );
794    println!();
795    println!("   {:<24}  {:>4}", "+ Confirmed", confirmed);
796    println!("   {:<24}  {:>4}", "- Rejected", rejected);
797    println!("   {:<24}  {:>4}", "~ Partial", partial);
798    println!("   {:<24}  {:>4}", "x Skipped", skipped);
799    println!();
800    println!("   {:<24}  {:>4}", "Still pending:", still_pending);
801    println!("   {:<24}  {:>3}%", "Session precision:", session_precision);
802    println!("   {:<24}  {:>3}%", "Overall coverage:", overall_coverage);
803
804    println!();
805    if session_precision >= 70 {
806        println!("   Precision diagnostic: calibrated — detected conventions are well-aligned");
807    } else {
808        println!(
809            "   Precision diagnostic: low precision — consider re-reviewing flagged conventions"
810        );
811    }
812
813    if context.already_confirmed > 0 || total_decided > 0 {
814        println!("\n   Knowledge graph updated.");
815    } else {
816        println!("\n   No actions; graph unchanged.");
817    }
818}
819
820fn levenshtein_distance(a: &str, b: &str) -> usize {
821    let a_chars: Vec<char> = a.chars().collect();
822    let b_chars: Vec<char> = b.chars().collect();
823    let a_len = a_chars.len();
824    let b_len = b_chars.len();
825
826    if a_len == 0 {
827        return b_len;
828    }
829    if b_len == 0 {
830        return a_len;
831    }
832
833    let mut prev: Vec<usize> = (0..=b_len).collect();
834    let mut curr = vec![0usize; b_len + 1];
835
836    for i in 1..=a_len {
837        curr[0] = i;
838        for j in 1..=b_len {
839            let cost = if a_chars[i - 1] == b_chars[j - 1] {
840                0
841            } else {
842                1
843            };
844            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
845        }
846        std::mem::swap(&mut prev, &mut curr);
847    }
848
849    prev[b_len]
850}
851
852pub fn fuzzy_match(query: &str, candidate: &str) -> bool {
853    if query.is_empty() {
854        return true;
855    }
856
857    if candidate.contains(query) {
858        return true;
859    }
860
861    let candidate_chars: Vec<char> = candidate.chars().collect();
862    let query_len = query.chars().count();
863
864    for window_len in query_len.saturating_sub(2)..=(query_len + 2).min(candidate_chars.len()) {
865        if window_len == 0 {
866            continue;
867        }
868        for i in 0..=candidate_chars.len().saturating_sub(window_len) {
869            let window: String = candidate_chars[i..i + window_len].iter().collect();
870            let dist = levenshtein_distance(query, &window);
871            if dist <= 2 {
872                return true;
873            }
874        }
875    }
876
877    candidate.to_lowercase().contains(&query.to_lowercase())
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    fn make_item(node_id: i64, description: &str) -> ConventionItem {
885        ConventionItem {
886            node_id,
887            description: description.to_owned(),
888            nature: "convention".to_owned(),
889            weight: "strong".to_owned(),
890            confidence_pct: 80,
891            adoption_count: 8,
892            total_count: 10,
893            adoption_rate_pct: 80,
894            trend: "stable".to_owned(),
895            source: "auto_detected".to_owned(),
896            examples: Vec::new(),
897            snapshot_hash: 0,
898            description_hash: None,
899            example_index: 0,
900        }
901    }
902
903    fn make_item_with_examples(
904        node_id: i64,
905        description: &str,
906        n_examples: usize,
907    ) -> ConventionItem {
908        let mut item = make_item(node_id, description);
909        item.examples = (0..n_examples)
910            .map(|i| CodeExample {
911                file: format!("file_{i}.rs"),
912                line: (i as u32) + 1,
913                end_line: (i as u32) + 1,
914                snippet: format!("snippet_{i}"),
915                snippet_start_line: 0,
916            })
917            .collect();
918        item
919    }
920
921    fn compute_summary_stats(results: &[ReviewAction]) -> (usize, usize, usize, usize, u32) {
922        let confirmed = results
923            .iter()
924            .filter(|r| matches!(r, ReviewAction::Confirm { .. }))
925            .count();
926        let rejected = results
927            .iter()
928            .filter(|r| matches!(r, ReviewAction::Reject { .. }))
929            .count();
930        let partial = results
931            .iter()
932            .filter(|r| matches!(r, ReviewAction::Partial { .. }))
933            .count();
934        let skipped = results
935            .iter()
936            .filter(|r| matches!(r, ReviewAction::Skip { .. }))
937            .count();
938        let total_decided = confirmed.saturating_add(rejected).saturating_add(partial);
939        let precision = if total_decided > 0 {
940            (confirmed as f64 / total_decided as f64 * 100.0).round() as u32
941        } else {
942            0
943        };
944        (confirmed, rejected, partial, skipped, precision)
945    }
946
947    #[test]
948    fn code_example_uses_snippet_start_line_for_line_numbers() {
949        // When snippet_start_line is non-zero it should be read from JSON and
950        // stored on CodeExample so widgets can compute correct line numbers.
951        let ext = Some(serde_json::json!({
952            "evidence": [
953                {
954                    "file": "src/lib.rs",
955                    "line": 10,
956                    "end_line": 12,
957                    "snippet": "fn context_line() {}\nfn target_fn() {\n    do_thing();\n}",
958                    "snippet_start_line": 8
959                }
960            ]
961        }));
962
963        let examples = parse_evidence(&ext);
964        assert_eq!(examples.len(), 1);
965        let ex = &examples[0];
966        assert_eq!(ex.snippet_start_line, 8);
967        assert_eq!(ex.line, 10);
968
969        // Verify fallback: when snippet_start_line is absent it defaults to 0
970        let ext_no_start = Some(serde_json::json!({
971            "evidence": [
972                {
973                    "file": "src/lib.rs",
974                    "line": 5,
975                    "end_line": 5,
976                    "snippet": "let x = 1;"
977                }
978            ]
979        }));
980        let examples2 = parse_evidence(&ext_no_start);
981        assert_eq!(examples2.len(), 1);
982        assert_eq!(examples2[0].snippet_start_line, 0);
983    }
984
985    #[test]
986    fn app_next_previous_bounds() {
987        let conventions = vec![
988            ConventionItem {
989                node_id: 1,
990                description: "A".to_owned(),
991                nature: "convention".to_owned(),
992                weight: "strong".to_owned(),
993                confidence_pct: 90,
994                adoption_count: 10,
995                total_count: 10,
996                adoption_rate_pct: 100,
997                trend: "stable".to_owned(),
998                source: "auto_detected".to_owned(),
999                examples: Vec::new(),
1000                snapshot_hash: 0,
1001                description_hash: None,
1002                example_index: 0,
1003            },
1004            ConventionItem {
1005                node_id: 2,
1006                description: "B".to_owned(),
1007                nature: "convention".to_owned(),
1008                weight: "strong".to_owned(),
1009                confidence_pct: 80,
1010                adoption_count: 8,
1011                total_count: 10,
1012                adoption_rate_pct: 80,
1013                trend: "rising".to_owned(),
1014                source: "auto_detected".to_owned(),
1015                examples: Vec::new(),
1016                snapshot_hash: 0,
1017                description_hash: None,
1018                example_index: 0,
1019            },
1020        ];
1021        let mut app = App::new(conventions);
1022
1023        assert_eq!(app.current_index, 0);
1024        assert!(!app.review_complete);
1025
1026        app.previous();
1027        assert_eq!(app.current_index, 0);
1028
1029        app.next();
1030        assert_eq!(app.current_index, 1);
1031        assert!(app.review_complete);
1032
1033        app.next();
1034        assert_eq!(app.current_index, 1);
1035        assert!(app.review_complete);
1036
1037        app.previous();
1038        assert_eq!(app.current_index, 0);
1039        assert!(!app.review_complete);
1040    }
1041
1042    #[test]
1043    fn app_current_returns_none_when_empty() {
1044        let app = App::new(Vec::new());
1045        assert!(app.current().is_none());
1046        assert_eq!(app.total(), 0);
1047    }
1048
1049    #[test]
1050    fn app_acted_on_tracking() {
1051        let conventions = vec![
1052            ConventionItem {
1053                node_id: 1,
1054                description: "A".to_owned(),
1055                nature: "convention".to_owned(),
1056                weight: "strong".to_owned(),
1057                confidence_pct: 90,
1058                adoption_count: 10,
1059                total_count: 10,
1060                adoption_rate_pct: 100,
1061                trend: "stable".to_owned(),
1062                source: "auto_detected".to_owned(),
1063                examples: Vec::new(),
1064                snapshot_hash: 0,
1065                description_hash: None,
1066                example_index: 0,
1067            },
1068            ConventionItem {
1069                node_id: 2,
1070                description: "B".to_owned(),
1071                nature: "convention".to_owned(),
1072                weight: "strong".to_owned(),
1073                confidence_pct: 80,
1074                adoption_count: 8,
1075                total_count: 10,
1076                adoption_rate_pct: 80,
1077                trend: "rising".to_owned(),
1078                source: "auto_detected".to_owned(),
1079                examples: Vec::new(),
1080                snapshot_hash: 0,
1081                description_hash: None,
1082                example_index: 0,
1083            },
1084            ConventionItem {
1085                node_id: 3,
1086                description: "C".to_owned(),
1087                nature: "convention".to_owned(),
1088                weight: "strong".to_owned(),
1089                confidence_pct: 70,
1090                adoption_count: 7,
1091                total_count: 10,
1092                adoption_rate_pct: 70,
1093                trend: "rising".to_owned(),
1094                source: "auto_detected".to_owned(),
1095                examples: Vec::new(),
1096                snapshot_hash: 0,
1097                description_hash: None,
1098                example_index: 0,
1099            },
1100        ];
1101        let mut app = App::new(conventions);
1102
1103        assert!(!app.is_acted_on(0));
1104        assert!(!app.is_acted_on(1));
1105        assert!(!app.all_acted_on());
1106
1107        app.mark_acted_on(0);
1108        assert!(app.is_acted_on(0));
1109        assert!(!app.is_acted_on(1));
1110        assert!(!app.all_acted_on());
1111
1112        app.mark_acted_on(1);
1113        app.mark_acted_on(2);
1114        assert!(app.all_acted_on());
1115    }
1116
1117    #[test]
1118    fn app_advance_to_next_unreviewed() {
1119        let conventions = vec![
1120            ConventionItem {
1121                node_id: 1,
1122                description: "A".to_owned(),
1123                nature: "convention".to_owned(),
1124                weight: "strong".to_owned(),
1125                confidence_pct: 90,
1126                adoption_count: 10,
1127                total_count: 10,
1128                adoption_rate_pct: 100,
1129                trend: "stable".to_owned(),
1130                source: "auto_detected".to_owned(),
1131                examples: Vec::new(),
1132                snapshot_hash: 0,
1133                description_hash: None,
1134                example_index: 0,
1135            },
1136            ConventionItem {
1137                node_id: 2,
1138                description: "B".to_owned(),
1139                nature: "convention".to_owned(),
1140                weight: "strong".to_owned(),
1141                confidence_pct: 80,
1142                adoption_count: 8,
1143                total_count: 10,
1144                adoption_rate_pct: 80,
1145                trend: "rising".to_owned(),
1146                source: "auto_detected".to_owned(),
1147                examples: Vec::new(),
1148                snapshot_hash: 0,
1149                description_hash: None,
1150                example_index: 0,
1151            },
1152            ConventionItem {
1153                node_id: 3,
1154                description: "C".to_owned(),
1155                nature: "convention".to_owned(),
1156                weight: "strong".to_owned(),
1157                confidence_pct: 70,
1158                adoption_count: 7,
1159                total_count: 10,
1160                adoption_rate_pct: 70,
1161                trend: "rising".to_owned(),
1162                source: "auto_detected".to_owned(),
1163                examples: Vec::new(),
1164                snapshot_hash: 0,
1165                description_hash: None,
1166                example_index: 0,
1167            },
1168        ];
1169        let mut app = App::new(conventions);
1170
1171        // Start at index 0, advance wraps to 1
1172        app.mark_acted_on(0);
1173        app.advance_to_next_unreviewed();
1174        assert_eq!(app.current_index, 1);
1175        assert!(!app.quit);
1176
1177        // Mark 1 as acted and advance wraps to 2
1178        app.mark_acted_on(1);
1179        app.advance_to_next_unreviewed();
1180        assert_eq!(app.current_index, 2);
1181        assert!(!app.quit);
1182
1183        // Mark 2 as acted and advance wraps back to find 0, but 0 is also acted → all acted → quit
1184        app.mark_acted_on(2);
1185        app.advance_to_next_unreviewed();
1186        assert!(app.quit);
1187    }
1188
1189    #[test]
1190    fn app_advance_skips_acted_items() {
1191        let conventions = vec![
1192            ConventionItem {
1193                node_id: 1,
1194                description: "A".to_owned(),
1195                nature: "convention".to_owned(),
1196                weight: "strong".to_owned(),
1197                confidence_pct: 90,
1198                adoption_count: 10,
1199                total_count: 10,
1200                adoption_rate_pct: 100,
1201                trend: "stable".to_owned(),
1202                source: "auto_detected".to_owned(),
1203                examples: Vec::new(),
1204                snapshot_hash: 0,
1205                description_hash: None,
1206                example_index: 0,
1207            },
1208            ConventionItem {
1209                node_id: 2,
1210                description: "B".to_owned(),
1211                nature: "convention".to_owned(),
1212                weight: "strong".to_owned(),
1213                confidence_pct: 80,
1214                adoption_count: 8,
1215                total_count: 10,
1216                adoption_rate_pct: 80,
1217                trend: "rising".to_owned(),
1218                source: "auto_detected".to_owned(),
1219                examples: Vec::new(),
1220                snapshot_hash: 0,
1221                description_hash: None,
1222                example_index: 0,
1223            },
1224            ConventionItem {
1225                node_id: 3,
1226                description: "C".to_owned(),
1227                nature: "convention".to_owned(),
1228                weight: "strong".to_owned(),
1229                confidence_pct: 70,
1230                adoption_count: 7,
1231                total_count: 10,
1232                adoption_rate_pct: 70,
1233                trend: "rising".to_owned(),
1234                source: "auto_detected".to_owned(),
1235                examples: Vec::new(),
1236                snapshot_hash: 0,
1237                description_hash: None,
1238                example_index: 0,
1239            },
1240        ];
1241        let mut app = App::new(conventions);
1242
1243        // Act on 0, skip to 1 — but mark 1 as already acted. Should go to 2.
1244        app.mark_acted_on(0);
1245        app.mark_acted_on(1);
1246        app.current_index = 0;
1247        app.advance_to_next_unreviewed();
1248        assert_eq!(app.current_index, 2);
1249        assert!(!app.quit);
1250    }
1251
1252    #[test]
1253    fn review_action_confirm() {
1254        let action = ReviewAction::Confirm {
1255            node_id: 42,
1256            description: "test".to_owned(),
1257            examples: Vec::new(),
1258        };
1259        assert!(matches!(action, ReviewAction::Confirm { node_id: 42, .. }));
1260    }
1261
1262    #[test]
1263    fn review_action_reject() {
1264        let action = ReviewAction::Reject {
1265            node_id: 7,
1266            snapshot_hash: 12345,
1267        };
1268        assert!(matches!(action, ReviewAction::Reject { node_id: 7, .. }));
1269    }
1270
1271    #[test]
1272    fn review_action_partial() {
1273        let action = ReviewAction::Partial {
1274            node_id: 3,
1275            description: "test".to_owned(),
1276            original_node_id: 3,
1277        };
1278        assert!(matches!(action, ReviewAction::Partial { node_id: 3, .. }));
1279    }
1280
1281    #[test]
1282    fn review_action_skip() {
1283        let action = ReviewAction::Skip { node_id: 1 };
1284        assert!(matches!(action, ReviewAction::Skip { node_id: 1 }));
1285    }
1286
1287    #[test]
1288    fn compute_snapshot_hash_consistent() {
1289        let ext = Some(r#"{"source":"auto_detected","trend":"stable"}"#.to_owned());
1290        let h1 = compute_snapshot_hash(&ext);
1291        let h2 = compute_snapshot_hash(&ext);
1292        assert_eq!(h1, h2);
1293
1294        let ext2 = Some(r#"{"source":"auto_detected","trend":"rising"}"#.to_owned());
1295        let h3 = compute_snapshot_hash(&ext2);
1296        assert_ne!(h1, h3);
1297    }
1298
1299    #[test]
1300    fn compute_snapshot_hash_null_is_consistent() {
1301        let h1 = compute_snapshot_hash(&None);
1302        let h2 = compute_snapshot_hash(&None);
1303        assert_eq!(h1, h2);
1304    }
1305
1306    #[test]
1307    fn show_summary_empty_results() {
1308        let results: Vec<ReviewAction> = vec![];
1309        let (_confirmed, _rejected, _partial, _skipped, precision) =
1310            compute_summary_stats(&results);
1311        assert_eq!(precision, 0);
1312    }
1313
1314    #[test]
1315    fn show_summary_all_confirmed() {
1316        let results = vec![
1317            ReviewAction::Confirm {
1318                node_id: 1,
1319                description: "A".to_owned(),
1320                examples: Vec::new(),
1321            },
1322            ReviewAction::Confirm {
1323                node_id: 2,
1324                description: "B".to_owned(),
1325                examples: Vec::new(),
1326            },
1327            ReviewAction::Confirm {
1328                node_id: 3,
1329                description: "C".to_owned(),
1330                examples: Vec::new(),
1331            },
1332        ];
1333        let (confirmed, rejected, partial, skipped, precision) = compute_summary_stats(&results);
1334        assert_eq!(confirmed, 3);
1335        assert_eq!(rejected, 0);
1336        assert_eq!(partial, 0);
1337        assert_eq!(skipped, 0);
1338        assert_eq!(precision, 100);
1339    }
1340
1341    #[test]
1342    fn show_summary_mixed_decisions() {
1343        let results = vec![
1344            ReviewAction::Confirm {
1345                node_id: 1,
1346                description: "A".to_owned(),
1347                examples: Vec::new(),
1348            },
1349            ReviewAction::Reject {
1350                node_id: 2,
1351                snapshot_hash: 0,
1352            },
1353            ReviewAction::Partial {
1354                node_id: 3,
1355                description: "C".to_owned(),
1356                original_node_id: 3,
1357            },
1358            ReviewAction::Skip { node_id: 4 },
1359        ];
1360        let (confirmed, rejected, partial, skipped, precision) = compute_summary_stats(&results);
1361        assert_eq!(confirmed, 1);
1362        assert_eq!(rejected, 1);
1363        assert_eq!(partial, 1);
1364        assert_eq!(skipped, 1);
1365        assert_eq!(precision, 33);
1366    }
1367
1368    #[test]
1369    fn show_summary_high_precision_status() {
1370        let results = vec![
1371            ReviewAction::Confirm {
1372                node_id: 1,
1373                description: "A".to_owned(),
1374                examples: Vec::new(),
1375            },
1376            ReviewAction::Confirm {
1377                node_id: 2,
1378                description: "B".to_owned(),
1379                examples: Vec::new(),
1380            },
1381            ReviewAction::Reject {
1382                node_id: 3,
1383                snapshot_hash: 0,
1384            },
1385        ];
1386        let (_confirmed, _rejected, _partial, _skipped, precision) =
1387            compute_summary_stats(&results);
1388        assert_eq!(precision, 67);
1389    }
1390
1391    #[test]
1392    fn show_summary_low_precision_status() {
1393        let results = vec![
1394            ReviewAction::Confirm {
1395                node_id: 1,
1396                description: "A".to_owned(),
1397                examples: Vec::new(),
1398            },
1399            ReviewAction::Reject {
1400                node_id: 2,
1401                snapshot_hash: 0,
1402            },
1403            ReviewAction::Reject {
1404                node_id: 3,
1405                snapshot_hash: 0,
1406            },
1407            ReviewAction::Reject {
1408                node_id: 4,
1409                snapshot_hash: 0,
1410            },
1411        ];
1412        let (confirmed, rejected, _partial, _skipped, precision) = compute_summary_stats(&results);
1413        assert_eq!(confirmed, 1);
1414        assert_eq!(rejected, 3);
1415        assert_eq!(precision, 25);
1416        assert!(precision < 70);
1417    }
1418
1419    #[test]
1420    fn show_summary_only_skipped() {
1421        let results = vec![
1422            ReviewAction::Skip { node_id: 1 },
1423            ReviewAction::Skip { node_id: 2 },
1424        ];
1425        let (confirmed, rejected, partial, skipped, precision) = compute_summary_stats(&results);
1426        assert_eq!(confirmed, 0);
1427        assert_eq!(rejected, 0);
1428        assert_eq!(partial, 0);
1429        assert_eq!(skipped, 2);
1430        assert_eq!(precision, 0);
1431    }
1432
1433    #[test]
1434    fn show_summary_all_rejected() {
1435        let results = vec![
1436            ReviewAction::Reject {
1437                node_id: 1,
1438                snapshot_hash: 0,
1439            },
1440            ReviewAction::Reject {
1441                node_id: 2,
1442                snapshot_hash: 0,
1443            },
1444        ];
1445        let (confirmed, rejected, _partial, _skipped, precision) = compute_summary_stats(&results);
1446        assert_eq!(confirmed, 0);
1447        assert_eq!(rejected, 2);
1448        assert_eq!(precision, 0);
1449    }
1450
1451    #[test]
1452    fn show_summary_precision_rounding() {
1453        let results = vec![
1454            ReviewAction::Confirm {
1455                node_id: 1,
1456                description: "A".to_owned(),
1457                examples: Vec::new(),
1458            },
1459            ReviewAction::Confirm {
1460                node_id: 2,
1461                description: "B".to_owned(),
1462                examples: Vec::new(),
1463            },
1464            ReviewAction::Reject {
1465                node_id: 3,
1466                snapshot_hash: 0,
1467            },
1468            ReviewAction::Reject {
1469                node_id: 4,
1470                snapshot_hash: 0,
1471            },
1472            ReviewAction::Reject {
1473                node_id: 5,
1474                snapshot_hash: 0,
1475            },
1476        ];
1477        let (confirmed, rejected, _partial, _skipped, precision) = compute_summary_stats(&results);
1478        assert_eq!(confirmed, 2);
1479        assert_eq!(rejected, 3);
1480        assert_eq!(precision, 40);
1481    }
1482
1483    #[test]
1484    fn show_summary_status_threshold_below_70() {
1485        // 7/12 = 58.3% -> 58% should be low precision
1486        let results = vec![
1487            ReviewAction::Confirm {
1488                node_id: 1,
1489                description: "A".to_owned(),
1490                examples: Vec::new(),
1491            },
1492            ReviewAction::Confirm {
1493                node_id: 2,
1494                description: "B".to_owned(),
1495                examples: Vec::new(),
1496            },
1497            ReviewAction::Confirm {
1498                node_id: 3,
1499                description: "C".to_owned(),
1500                examples: Vec::new(),
1501            },
1502            ReviewAction::Confirm {
1503                node_id: 4,
1504                description: "D".to_owned(),
1505                examples: Vec::new(),
1506            },
1507            ReviewAction::Confirm {
1508                node_id: 5,
1509                description: "E".to_owned(),
1510                examples: Vec::new(),
1511            },
1512            ReviewAction::Confirm {
1513                node_id: 6,
1514                description: "F".to_owned(),
1515                examples: Vec::new(),
1516            },
1517            ReviewAction::Confirm {
1518                node_id: 7,
1519                description: "G".to_owned(),
1520                examples: Vec::new(),
1521            },
1522            ReviewAction::Reject {
1523                node_id: 8,
1524                snapshot_hash: 0,
1525            },
1526            ReviewAction::Reject {
1527                node_id: 9,
1528                snapshot_hash: 0,
1529            },
1530            ReviewAction::Reject {
1531                node_id: 10,
1532                snapshot_hash: 0,
1533            },
1534            ReviewAction::Reject {
1535                node_id: 11,
1536                snapshot_hash: 0,
1537            },
1538            ReviewAction::Reject {
1539                node_id: 12,
1540                snapshot_hash: 0,
1541            },
1542        ];
1543        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1544        // 7/12 = 58.3% -> 58%
1545        assert_eq!(confirmed, 7);
1546        assert_eq!(rejected, 5);
1547        assert_eq!(precision, 58);
1548        assert!(precision < 70);
1549    }
1550
1551    #[test]
1552    fn show_summary_status_below_70() {
1553        // 6/9 = 66.7% -> 67% should be below calibrated
1554        let results = vec![
1555            ReviewAction::Confirm {
1556                node_id: 1,
1557                description: "A".to_owned(),
1558                examples: Vec::new(),
1559            },
1560            ReviewAction::Confirm {
1561                node_id: 2,
1562                description: "B".to_owned(),
1563                examples: Vec::new(),
1564            },
1565            ReviewAction::Confirm {
1566                node_id: 3,
1567                description: "C".to_owned(),
1568                examples: Vec::new(),
1569            },
1570            ReviewAction::Confirm {
1571                node_id: 4,
1572                description: "D".to_owned(),
1573                examples: Vec::new(),
1574            },
1575            ReviewAction::Confirm {
1576                node_id: 5,
1577                description: "E".to_owned(),
1578                examples: Vec::new(),
1579            },
1580            ReviewAction::Confirm {
1581                node_id: 6,
1582                description: "F".to_owned(),
1583                examples: Vec::new(),
1584            },
1585            ReviewAction::Reject {
1586                node_id: 7,
1587                snapshot_hash: 0,
1588            },
1589            ReviewAction::Reject {
1590                node_id: 8,
1591                snapshot_hash: 0,
1592            },
1593            ReviewAction::Reject {
1594                node_id: 9,
1595                snapshot_hash: 0,
1596            },
1597        ];
1598        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1599        // 6/9 = 66.7% -> 67%
1600        assert_eq!(confirmed, 6);
1601        assert_eq!(rejected, 3);
1602        assert_eq!(precision, 67);
1603        assert!(precision < 70);
1604    }
1605
1606    #[test]
1607    fn fuzzy_match_exact_substring() {
1608        assert!(fuzzy_match("error", "error handling"));
1609        assert!(fuzzy_match("ERROR", "error handling"));
1610        assert!(fuzzy_match("log", "logging is done via tracing"));
1611    }
1612
1613    #[test]
1614    fn fuzzy_match_fuzzy_typo() {
1615        assert!(fuzzy_match("err", "error handling"));
1616        assert!(fuzzy_match("loging", "logging"));
1617        assert!(fuzzy_match("handlng", "error handling"));
1618    }
1619
1620    #[test]
1621    fn fuzzy_match_no_match() {
1622        assert!(!fuzzy_match("xyzzy", "error handling"));
1623        assert!(!fuzzy_match("completelydifferent", "error handling"));
1624    }
1625
1626    #[test]
1627    fn fuzzy_match_empty_query_matches_all() {
1628        assert!(fuzzy_match("", "anything"));
1629        assert!(fuzzy_match("", ""));
1630    }
1631
1632    #[test]
1633    fn levenshtein_distance_identical() {
1634        assert_eq!(levenshtein_distance("abc", "abc"), 0);
1635    }
1636
1637    #[test]
1638    fn levenshtein_distance_one_substitution() {
1639        assert_eq!(levenshtein_distance("abc", "adc"), 1);
1640    }
1641
1642    #[test]
1643    fn levenshtein_distance_empty() {
1644        assert_eq!(levenshtein_distance("", "abc"), 3);
1645        assert_eq!(levenshtein_distance("abc", ""), 3);
1646    }
1647
1648    #[test]
1649    fn precision_all_confirmed() {
1650        let results: Vec<ReviewAction> = (0..10)
1651            .map(|i| ReviewAction::Confirm {
1652                node_id: i,
1653                description: "ok".to_owned(),
1654                examples: Vec::new(),
1655            })
1656            .collect();
1657        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1658        assert_eq!(confirmed, 10);
1659        assert_eq!(rejected, 0);
1660        assert_eq!(precision, 100);
1661    }
1662
1663    #[test]
1664    fn precision_all_rejected() {
1665        let results: Vec<ReviewAction> = (0..5)
1666            .map(|i| ReviewAction::Reject {
1667                node_id: i,
1668                snapshot_hash: 0,
1669            })
1670            .collect();
1671        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1672        assert_eq!(confirmed, 0);
1673        assert_eq!(rejected, 5);
1674        assert_eq!(precision, 0);
1675    }
1676
1677    #[test]
1678    fn precision_all_skipped() {
1679        let results: Vec<ReviewAction> =
1680            (0..5).map(|i| ReviewAction::Skip { node_id: i }).collect();
1681        let (confirmed, rejected, _, skipped, precision) = compute_summary_stats(&results);
1682        assert_eq!(confirmed, 0);
1683        assert_eq!(rejected, 0);
1684        assert_eq!(skipped, 5);
1685        assert_eq!(precision, 0);
1686    }
1687
1688    #[test]
1689    fn show_summary_status_threshold_at_exactly_70() {
1690        let results = vec![
1691            ReviewAction::Confirm {
1692                node_id: 1,
1693                description: "A".to_owned(),
1694                examples: Vec::new(),
1695            },
1696            ReviewAction::Confirm {
1697                node_id: 2,
1698                description: "B".to_owned(),
1699                examples: Vec::new(),
1700            },
1701            ReviewAction::Confirm {
1702                node_id: 3,
1703                description: "C".to_owned(),
1704                examples: Vec::new(),
1705            },
1706            ReviewAction::Confirm {
1707                node_id: 4,
1708                description: "D".to_owned(),
1709                examples: Vec::new(),
1710            },
1711            ReviewAction::Confirm {
1712                node_id: 5,
1713                description: "E".to_owned(),
1714                examples: Vec::new(),
1715            },
1716            ReviewAction::Confirm {
1717                node_id: 6,
1718                description: "F".to_owned(),
1719                examples: Vec::new(),
1720            },
1721            ReviewAction::Confirm {
1722                node_id: 7,
1723                description: "G".to_owned(),
1724                examples: Vec::new(),
1725            },
1726            ReviewAction::Reject {
1727                node_id: 8,
1728                snapshot_hash: 0,
1729            },
1730            ReviewAction::Reject {
1731                node_id: 9,
1732                snapshot_hash: 0,
1733            },
1734            ReviewAction::Reject {
1735                node_id: 10,
1736                snapshot_hash: 0,
1737            },
1738        ];
1739        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1740        assert_eq!(confirmed, 7);
1741        assert_eq!(rejected, 3);
1742        assert_eq!(precision, 70);
1743    }
1744
1745    #[test]
1746    fn show_summary_status_threshold_at_69() {
1747        let results = vec![
1748            ReviewAction::Confirm {
1749                node_id: 1,
1750                description: "A".to_owned(),
1751                examples: Vec::new(),
1752            },
1753            ReviewAction::Confirm {
1754                node_id: 2,
1755                description: "B".to_owned(),
1756                examples: Vec::new(),
1757            },
1758            ReviewAction::Confirm {
1759                node_id: 3,
1760                description: "C".to_owned(),
1761                examples: Vec::new(),
1762            },
1763            ReviewAction::Confirm {
1764                node_id: 4,
1765                description: "D".to_owned(),
1766                examples: Vec::new(),
1767            },
1768            ReviewAction::Confirm {
1769                node_id: 5,
1770                description: "E".to_owned(),
1771                examples: Vec::new(),
1772            },
1773            ReviewAction::Confirm {
1774                node_id: 6,
1775                description: "F".to_owned(),
1776                examples: Vec::new(),
1777            },
1778            ReviewAction::Confirm {
1779                node_id: 7,
1780                description: "G".to_owned(),
1781                examples: Vec::new(),
1782            },
1783            ReviewAction::Confirm {
1784                node_id: 8,
1785                description: "H".to_owned(),
1786                examples: Vec::new(),
1787            },
1788            ReviewAction::Confirm {
1789                node_id: 9,
1790                description: "I".to_owned(),
1791                examples: Vec::new(),
1792            },
1793            ReviewAction::Reject {
1794                node_id: 10,
1795                snapshot_hash: 0,
1796            },
1797            ReviewAction::Reject {
1798                node_id: 11,
1799                snapshot_hash: 0,
1800            },
1801            ReviewAction::Reject {
1802                node_id: 12,
1803                snapshot_hash: 0,
1804            },
1805            ReviewAction::Reject {
1806                node_id: 13,
1807                snapshot_hash: 0,
1808            },
1809        ];
1810        let (confirmed, rejected, _, _, precision) = compute_summary_stats(&results);
1811        assert_eq!(confirmed, 9);
1812        assert_eq!(rejected, 4);
1813        assert_eq!(precision, 69);
1814        assert!(precision < 70);
1815    }
1816
1817    // ── levenshtein_distance / fuzzy_match ──────────────────────────
1818
1819    #[test]
1820    fn levenshtein_distance_identical_is_zero() {
1821        assert_eq!(levenshtein_distance("hello", "hello"), 0);
1822        assert_eq!(levenshtein_distance("", ""), 0);
1823    }
1824
1825    #[test]
1826    fn levenshtein_distance_empty_inputs() {
1827        assert_eq!(levenshtein_distance("", "abc"), 3);
1828        assert_eq!(levenshtein_distance("abc", ""), 3);
1829    }
1830
1831    #[test]
1832    fn levenshtein_distance_single_edit() {
1833        assert_eq!(levenshtein_distance("kitten", "sitten"), 1);
1834        assert_eq!(levenshtein_distance("kitten", "kittens"), 1);
1835        assert_eq!(levenshtein_distance("abcd", "abc"), 1);
1836    }
1837
1838    #[test]
1839    fn levenshtein_distance_classic_example() {
1840        // kitten → sitting: 3 edits (k→s, e→i, +g)
1841        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
1842    }
1843
1844    #[test]
1845    fn fuzzy_match_empty_query_matches_anything() {
1846        assert!(fuzzy_match("", "anything"));
1847        assert!(fuzzy_match("", ""));
1848    }
1849
1850    #[test]
1851    fn fuzzy_match_substring_matches() {
1852        assert!(fuzzy_match("error", "error handling"));
1853        assert!(fuzzy_match("hand", "error handling"));
1854    }
1855
1856    #[test]
1857    fn fuzzy_match_close_typo_matches() {
1858        // Within 2 edits of substring window.
1859        assert!(fuzzy_match("eror", "error handling"));
1860        assert!(fuzzy_match("erorr", "error handling"));
1861    }
1862
1863    #[test]
1864    fn fuzzy_match_far_query_does_not_match() {
1865        assert!(!fuzzy_match("xyzqq", "error handling"));
1866    }
1867
1868    #[test]
1869    fn fuzzy_match_falls_back_to_lowercase_substring() {
1870        assert!(fuzzy_match("error", "Error Handling"));
1871    }
1872
1873    // ── App search / filter behavior ────────────────────────────────
1874
1875    fn three_item_app() -> App {
1876        let conventions = vec![
1877            make_item(1, "Use thiserror for error handling"),
1878            make_item(2, "Snake case naming convention"),
1879            make_item(3, "Always Result<T, Error>"),
1880        ];
1881        App::new(conventions)
1882    }
1883
1884    #[test]
1885    fn app_filtered_total_starts_at_full_list() {
1886        let app = three_item_app();
1887        assert_eq!(app.filtered_total(), 3);
1888        assert_eq!(app.filtered_current_index(), 0);
1889    }
1890
1891    #[test]
1892    fn app_filtered_next_and_previous_traverse_all() {
1893        let mut app = three_item_app();
1894        assert_eq!(app.current_index, 0);
1895        app.filtered_next();
1896        assert_eq!(app.current_index, 1);
1897        app.filtered_next();
1898        assert_eq!(app.current_index, 2);
1899        // At end — must not move past.
1900        app.filtered_next();
1901        assert_eq!(app.current_index, 2);
1902
1903        app.filtered_previous();
1904        assert_eq!(app.current_index, 1);
1905        app.filtered_previous();
1906        assert_eq!(app.current_index, 0);
1907        // At start — must not move before.
1908        app.filtered_previous();
1909        assert_eq!(app.current_index, 0);
1910    }
1911
1912    #[test]
1913    fn app_push_search_char_filters_list() {
1914        let mut app = three_item_app();
1915        app.push_search_char('e');
1916        app.push_search_char('r');
1917        app.push_search_char('r');
1918        app.push_search_char('o');
1919        app.push_search_char('r');
1920        // "error" matches items 1 and 3.
1921        assert_eq!(app.search_query, "error");
1922        assert!(app.filtered_total() >= 1);
1923        // First filtered match becomes current.
1924        let cur = app.current().expect("current should be set");
1925        assert!(cur.description.to_lowercase().contains("error"));
1926    }
1927
1928    #[test]
1929    fn app_pop_search_char_shrinks_query() {
1930        let mut app = three_item_app();
1931        for c in "snake".chars() {
1932            app.push_search_char(c);
1933        }
1934        assert_eq!(app.search_query, "snake");
1935        app.pop_search_char();
1936        assert_eq!(app.search_query, "snak");
1937        // Empty pop cancels search and restores full list.
1938        for _ in 0..app.search_query.chars().count() {
1939            app.pop_search_char();
1940        }
1941        assert!(app.search_query.is_empty());
1942        assert_eq!(app.filtered_total(), 3);
1943        assert!(!app.search_mode);
1944    }
1945
1946    #[test]
1947    fn app_lock_filter_locks_when_non_empty() {
1948        let mut app = three_item_app();
1949        app.search_mode = true;
1950        for c in "error".chars() {
1951            app.push_search_char(c);
1952        }
1953        let total_before = app.filtered_total();
1954        assert!(total_before >= 1);
1955        app.lock_filter();
1956        assert!(app.filter_locked);
1957        assert!(!app.search_mode);
1958    }
1959
1960    #[test]
1961    fn app_lock_filter_no_op_when_filter_empty() {
1962        let mut app = three_item_app();
1963        app.search_mode = true;
1964        // Search query that matches nothing.
1965        for c in "zzzzzzzz".chars() {
1966            app.push_search_char(c);
1967        }
1968        // If no matches, lock_filter returns without changing state.
1969        if app.filtered_indices.is_empty() {
1970            app.lock_filter();
1971            assert!(!app.filter_locked);
1972        }
1973    }
1974
1975    #[test]
1976    fn app_cancel_search_resets_state() {
1977        let mut app = three_item_app();
1978        app.search_mode = true;
1979        for c in "snake".chars() {
1980            app.push_search_char(c);
1981        }
1982        app.lock_filter();
1983        app.cancel_search();
1984        assert_eq!(app.search_query, "");
1985        assert!(!app.search_mode);
1986        assert!(!app.filter_locked);
1987        assert_eq!(app.filtered_total(), 3);
1988        assert_eq!(app.current_index, 0);
1989    }
1990
1991    #[test]
1992    fn app_filtered_current_returns_current_item() {
1993        let app = three_item_app();
1994        let cur = app.filtered_current().expect("should have current");
1995        assert_eq!(cur.node_id, 1);
1996    }
1997
1998    #[test]
1999    fn app_filtered_current_none_when_empty() {
2000        let app = App::new(Vec::new());
2001        assert!(app.filtered_current().is_none());
2002    }
2003
2004    // ── App example navigation ──────────────────────────────────────
2005
2006    #[test]
2007    fn app_example_total_reflects_current_item() {
2008        let mut app = App::new(vec![make_item_with_examples(1, "C", 3)]);
2009        assert_eq!(app.example_total(), 3);
2010        app.conventions.clear();
2011        assert_eq!(app.example_total(), 0);
2012    }
2013
2014    #[test]
2015    fn app_next_example_cycles() {
2016        let mut app = App::new(vec![make_item_with_examples(1, "C", 3)]);
2017        assert_eq!(app.current().unwrap().example_index, 0);
2018        app.next_example();
2019        assert_eq!(app.current().unwrap().example_index, 1);
2020        app.next_example();
2021        assert_eq!(app.current().unwrap().example_index, 2);
2022        app.next_example();
2023        // Wraps back to 0.
2024        assert_eq!(app.current().unwrap().example_index, 0);
2025    }
2026
2027    #[test]
2028    fn app_previous_example_wraps_at_zero() {
2029        let mut app = App::new(vec![make_item_with_examples(1, "C", 3)]);
2030        assert_eq!(app.current().unwrap().example_index, 0);
2031        app.previous_example();
2032        // Wraps to last.
2033        assert_eq!(app.current().unwrap().example_index, 2);
2034        app.previous_example();
2035        assert_eq!(app.current().unwrap().example_index, 1);
2036    }
2037
2038    #[test]
2039    fn app_next_example_no_op_with_one_example() {
2040        let mut app = App::new(vec![make_item_with_examples(1, "C", 1)]);
2041        app.next_example();
2042        assert_eq!(app.current().unwrap().example_index, 0);
2043        app.previous_example();
2044        assert_eq!(app.current().unwrap().example_index, 0);
2045    }
2046
2047    #[test]
2048    fn app_next_example_no_op_with_zero_examples() {
2049        let mut app = App::new(vec![make_item(1, "C")]);
2050        app.next_example();
2051        app.previous_example();
2052        assert_eq!(app.current().unwrap().example_index, 0);
2053    }
2054
2055    #[test]
2056    fn app_next_resets_example_index() {
2057        let mut app = App::new(vec![
2058            make_item_with_examples(1, "A", 3),
2059            make_item_with_examples(2, "B", 3),
2060        ]);
2061        app.next_example();
2062        app.next_example();
2063        assert_eq!(app.current().unwrap().example_index, 2);
2064        app.next();
2065        assert_eq!(app.current_index, 1);
2066        // example_index resets when moving between conventions.
2067        assert_eq!(app.current().unwrap().example_index, 0);
2068        app.previous();
2069        assert_eq!(app.current().unwrap().example_index, 0);
2070    }
2071
2072    // ── parse_evidence edge cases ────────────────────────────────────
2073
2074    #[test]
2075    fn parse_evidence_with_no_ext_returns_empty() {
2076        let examples = parse_evidence(&None);
2077        assert!(examples.is_empty());
2078    }
2079
2080    #[test]
2081    fn parse_evidence_no_evidence_key_returns_empty() {
2082        let ext = Some(serde_json::json!({"source": "auto_detected"}));
2083        assert!(parse_evidence(&ext).is_empty());
2084    }
2085
2086    #[test]
2087    fn parse_evidence_evidence_not_array_returns_empty() {
2088        let ext = Some(serde_json::json!({"evidence": "not-an-array"}));
2089        assert!(parse_evidence(&ext).is_empty());
2090    }
2091
2092    #[test]
2093    fn parse_evidence_skips_rows_with_empty_file_and_snippet() {
2094        let ext = Some(serde_json::json!({
2095            "evidence": [
2096                {"file": "", "snippet": ""},
2097                {"file": "a.rs", "snippet": "code"},
2098                {"file": "", "line": 0, "snippet": ""},
2099            ]
2100        }));
2101        let examples = parse_evidence(&ext);
2102        assert_eq!(examples.len(), 1);
2103        assert_eq!(examples[0].file, "a.rs");
2104    }
2105
2106    #[test]
2107    fn parse_evidence_keeps_synthetic_composite_when_snippet_present() {
2108        let ext = Some(serde_json::json!({
2109            "evidence": [
2110                {"file": "", "snippet": "98 files match this convention"}
2111            ]
2112        }));
2113        let examples = parse_evidence(&ext);
2114        assert_eq!(examples.len(), 1);
2115        assert!(examples[0].file.is_empty());
2116        assert!(examples[0].snippet.contains("98 files"));
2117    }
2118
2119    #[test]
2120    fn parse_evidence_end_line_defaults_to_line() {
2121        let ext = Some(serde_json::json!({
2122            "evidence": [
2123                {"file": "a.rs", "line": 10, "snippet": "x"}
2124            ]
2125        }));
2126        let examples = parse_evidence(&ext);
2127        assert_eq!(examples.len(), 1);
2128        assert_eq!(examples[0].line, 10);
2129        assert_eq!(examples[0].end_line, 10);
2130    }
2131
2132    #[test]
2133    fn parse_evidence_handles_snippet_object_with_content() {
2134        let ext = Some(serde_json::json!({
2135            "evidence": [
2136                {"file": "a.rs", "line": 1, "snippet": {"content": "x"}}
2137            ]
2138        }));
2139        let examples = parse_evidence(&ext);
2140        assert_eq!(examples.len(), 1);
2141        assert_eq!(examples[0].snippet, "x");
2142    }
2143
2144    // ── ReviewAction::node_id_if_reject ─────────────────────────────
2145
2146    #[test]
2147    fn node_id_if_reject_returns_id_for_all_variants() {
2148        let confirm = ReviewAction::Confirm {
2149            node_id: 1,
2150            description: "x".to_owned(),
2151            examples: Vec::new(),
2152        };
2153        let reject = ReviewAction::Reject {
2154            node_id: 2,
2155            snapshot_hash: 0,
2156        };
2157        let partial = ReviewAction::Partial {
2158            node_id: 3,
2159            description: "x".to_owned(),
2160            original_node_id: 3,
2161        };
2162        let skip = ReviewAction::Skip { node_id: 4 };
2163        assert_eq!(confirm.node_id_if_reject(), Some(1));
2164        assert_eq!(reject.node_id_if_reject(), Some(2));
2165        assert_eq!(partial.node_id_if_reject(), Some(3));
2166        assert_eq!(skip.node_id_if_reject(), Some(4));
2167    }
2168
2169    // ── show_summary direct invocation ──────────────────────────────
2170
2171    #[test]
2172    fn show_summary_runs_all_branches() {
2173        // Empty results, zero context → "No actions; graph unchanged" branch.
2174        show_summary(
2175            &[],
2176            &SummaryContext {
2177                total_in_scope: 0,
2178                already_confirmed: 0,
2179            },
2180        );
2181
2182        // High-precision branch with actions and existing confirmed.
2183        show_summary(
2184            &[
2185                ReviewAction::Confirm {
2186                    node_id: 1,
2187                    description: "A".to_owned(),
2188                    examples: Vec::new(),
2189                },
2190                ReviewAction::Reject {
2191                    node_id: 2,
2192                    snapshot_hash: 0,
2193                },
2194            ],
2195            &SummaryContext {
2196                total_in_scope: 5,
2197                already_confirmed: 3,
2198            },
2199        );
2200
2201        // Low-precision branch.
2202        show_summary(
2203            &[
2204                ReviewAction::Reject {
2205                    node_id: 1,
2206                    snapshot_hash: 0,
2207                },
2208                ReviewAction::Reject {
2209                    node_id: 2,
2210                    snapshot_hash: 0,
2211                },
2212            ],
2213            &SummaryContext {
2214                total_in_scope: 2,
2215                already_confirmed: 0,
2216            },
2217        );
2218    }
2219
2220    // ── apply_review_actions ────────────────────────────────────────
2221
2222    fn open_test_db() -> Arc<Mutex<rusqlite::Connection>> {
2223        let db = seshat_storage::Database::open(":memory:").expect("in-memory DB");
2224        db.connection().clone()
2225    }
2226
2227    #[test]
2228    fn apply_review_actions_empty_is_noop() {
2229        let conn = open_test_db();
2230        // Should return Ok without touching the DB.
2231        apply_review_actions(&conn, "main", &[]).unwrap();
2232    }
2233
2234    #[test]
2235    fn apply_review_actions_skip_only_succeeds() {
2236        let conn = open_test_db();
2237        // Skip actions are no-ops in the action loop, so the all-failed
2238        // rollback branch must NOT trigger.
2239        apply_review_actions(&conn, "main", &[ReviewAction::Skip { node_id: 1 }]).unwrap();
2240    }
2241
2242    #[test]
2243    fn apply_review_actions_confirm_persists_decision() {
2244        let conn = open_test_db();
2245        let description = "test confirm";
2246        let action = ReviewAction::Confirm {
2247            node_id: 0,
2248            description: description.to_owned(),
2249            examples: vec![CodeExample {
2250                file: "src/main.rs".to_owned(),
2251                line: 1,
2252                end_line: 2,
2253                snippet: "fn main() {}".to_owned(),
2254                snippet_start_line: 1,
2255            }],
2256        };
2257        apply_review_actions(&conn, "main", &[action]).unwrap();
2258
2259        // Verify the decisions table now has an approved row keyed by hash.
2260        let expected_hash = compute_description_hash(description);
2261        let repo = SqliteDecisionRepository::new(conn.clone());
2262        let decision = repo
2263            .get_by_hash(&expected_hash)
2264            .unwrap()
2265            .expect("approved decision row should exist");
2266        assert_eq!(decision.state, DecisionState::Approved);
2267        assert_eq!(decision.description, description);
2268        assert_eq!(decision.decided_on_branch, BranchId("main".to_owned()));
2269        assert_eq!(decision.examples.len(), 1);
2270        assert_eq!(decision.examples[0].file, "src/main.rs");
2271
2272        // No user-source node should be created by the TUI confirm path
2273        // anymore — decisions live in their own table.
2274        let user_node_count: i64 = {
2275            let g = lock_conn(&conn).unwrap();
2276            g.query_row(
2277                "SELECT COUNT(*) FROM nodes
2278                 WHERE branch_id = 'main'
2279                   AND json_extract(ext_data, '$.source') = 'user'",
2280                [],
2281                |r| r.get(0),
2282            )
2283            .unwrap()
2284        };
2285        assert_eq!(user_node_count, 0);
2286    }
2287
2288    #[test]
2289    fn apply_review_actions_partial_persists_decision_with_partial_state() {
2290        let conn = open_test_db();
2291        let description = "partial convention example";
2292        let action = ReviewAction::Partial {
2293            node_id: 7,
2294            description: description.to_owned(),
2295            original_node_id: 7,
2296        };
2297        apply_review_actions(&conn, "main", &[action]).unwrap();
2298
2299        let hash = compute_description_hash(description);
2300        let repo = SqliteDecisionRepository::new(conn.clone());
2301        let decision = repo
2302            .get_by_hash(&hash)
2303            .unwrap()
2304            .expect("partial decision row should exist");
2305        assert_eq!(decision.state, DecisionState::Partial);
2306        assert_eq!(decision.nature, DecisionNature::Preference);
2307        // The literal description is stored — no "Partial: ..." prefix anymore;
2308        // the state column carries that signal.
2309        assert_eq!(decision.description, description);
2310    }
2311
2312    #[test]
2313    fn count_confirmed_conventions_returns_zero_on_empty_db() {
2314        let conn = open_test_db();
2315        assert_eq!(count_confirmed_conventions(&conn), 0);
2316    }
2317
2318    #[test]
2319    fn count_confirmed_conventions_counts_only_approved_partial_recorded() {
2320        // AC: SELECT COUNT(*) FROM decisions WHERE state IN
2321        // ('approved','partial','recorded'). Rejected rows must be excluded.
2322        let conn = open_test_db();
2323        let mix = [
2324            ("approved 1", DecisionState::Approved),
2325            ("approved 2", DecisionState::Approved),
2326            ("partial 1", DecisionState::Partial),
2327            ("recorded 1", DecisionState::Recorded),
2328            ("recorded 2", DecisionState::Recorded),
2329            ("rejected 1", DecisionState::Rejected),
2330            ("rejected 2", DecisionState::Rejected),
2331        ];
2332        for (description, state) in mix {
2333            let hash = compute_description_hash(description);
2334            seed_decision_for_hash(&conn, "main", description, &hash, state);
2335        }
2336        // 2 approved + 1 partial + 2 recorded = 5; the 2 rejected are excluded.
2337        assert_eq!(count_confirmed_conventions(&conn), 5);
2338    }
2339
2340    #[test]
2341    fn count_confirmed_conventions_ignores_branch_filter() {
2342        // AC: "No branch_id filter" — a decision approved on any branch
2343        // counts toward the project-wide total once.
2344        let conn = open_test_db();
2345        seed_decision_for_hash(
2346            &conn,
2347            "main",
2348            "main convention",
2349            &compute_description_hash("main convention"),
2350            DecisionState::Approved,
2351        );
2352        seed_decision_for_hash(
2353            &conn,
2354            "feature/x",
2355            "feature convention",
2356            &compute_description_hash("feature convention"),
2357            DecisionState::Approved,
2358        );
2359        seed_decision_for_hash(
2360            &conn,
2361            "feature/y",
2362            "another feature convention",
2363            &compute_description_hash("another feature convention"),
2364            DecisionState::Partial,
2365        );
2366        // 3 confirmed across 3 different branches; the count is project-wide.
2367        assert_eq!(count_confirmed_conventions(&conn), 3);
2368    }
2369
2370    #[test]
2371    fn count_confirmed_conventions_handles_large_count() {
2372        // AC: "Unit tests cover ... large count". Seed 250 confirmed
2373        // decisions across the three confirmed-counting states and 50
2374        // rejected (which must NOT be counted).
2375        let conn = open_test_db();
2376        let confirmed_states = [
2377            DecisionState::Approved,
2378            DecisionState::Partial,
2379            DecisionState::Recorded,
2380        ];
2381        for i in 0..250 {
2382            let description = format!("confirmed convention #{i:03}");
2383            let hash = compute_description_hash(&description);
2384            seed_decision_for_hash(
2385                &conn,
2386                "main",
2387                &description,
2388                &hash,
2389                confirmed_states[i % confirmed_states.len()],
2390            );
2391        }
2392        for i in 0..50 {
2393            let description = format!("rejected convention #{i:03}");
2394            let hash = compute_description_hash(&description);
2395            seed_decision_for_hash(&conn, "main", &description, &hash, DecisionState::Rejected);
2396        }
2397        assert_eq!(count_confirmed_conventions(&conn), 250);
2398    }
2399
2400    #[test]
2401    fn query_conventions_for_review_empty_db_returns_empty() {
2402        let conn = open_test_db();
2403        let (items, branch) = query_conventions_for_review(&conn, "main").unwrap();
2404        assert!(items.is_empty());
2405        assert_eq!(branch, "main");
2406    }
2407
2408    /// Insert an auto-detected convention node and return its description_hash
2409    /// alongside its rowid. Helper for the LEFT JOIN tests below.
2410    fn seed_auto_convention(
2411        conn: &Arc<Mutex<rusqlite::Connection>>,
2412        branch_id: &str,
2413        description: &str,
2414        confidence: f64,
2415    ) -> String {
2416        let hash = compute_description_hash(description);
2417        let g = lock_conn(conn).unwrap();
2418        g.execute(
2419            "INSERT INTO nodes
2420                 (branch_id, nature, weight, confidence,
2421                  adoption_count, total_count, description, ext_data, description_hash)
2422             VALUES (?1, 'convention', 'strong', ?2, 5, 5, ?3,
2423                     json('{\"source\":\"auto_detected\"}'), ?4)",
2424            params![branch_id, confidence, description, hash],
2425        )
2426        .unwrap();
2427        hash
2428    }
2429
2430    fn seed_decision_for_hash(
2431        conn: &Arc<Mutex<rusqlite::Connection>>,
2432        branch_id: &str,
2433        description: &str,
2434        hash: &str,
2435        state: DecisionState,
2436    ) {
2437        let repo = SqliteDecisionRepository::new(conn.clone());
2438        repo.upsert(&Decision {
2439            description_hash: hash.to_owned(),
2440            description: description.to_owned(),
2441            state,
2442            nature: DecisionNature::Convention,
2443            weight: DecisionWeight::Strong,
2444            category: None,
2445            reason: None,
2446            examples: vec![],
2447            decided_on_branch: BranchId(branch_id.to_owned()),
2448            decided_at: 1_700_000_000,
2449            updated_at: 1_700_000_000,
2450        })
2451        .unwrap();
2452    }
2453
2454    #[test]
2455    fn query_conventions_for_review_excludes_decided_node() {
2456        // AC: insert two auto nodes, decide one (any state), only the
2457        // undecided one returns.
2458        let conn = open_test_db();
2459        let _decided_hash = seed_auto_convention(&conn, "main", "decided convention", 0.9);
2460        let _undecided_hash = seed_auto_convention(&conn, "main", "undecided convention", 0.8);
2461
2462        seed_decision_for_hash(
2463            &conn,
2464            "main",
2465            "decided convention",
2466            &compute_description_hash("decided convention"),
2467            DecisionState::Approved,
2468        );
2469
2470        let (items, _) = query_conventions_for_review(&conn, "main").unwrap();
2471        assert_eq!(
2472            items.len(),
2473            1,
2474            "exactly one undecided convention should remain"
2475        );
2476        assert_eq!(items[0].description, "undecided convention");
2477    }
2478
2479    #[test]
2480    fn query_conventions_for_review_excludes_decided_in_any_state() {
2481        // AC: bulk case — 100 auto nodes, 50 decided across all four states,
2482        // verify only 50 returned (the undecided ones).
2483        let conn = open_test_db();
2484        let states = [
2485            DecisionState::Approved,
2486            DecisionState::Rejected,
2487            DecisionState::Partial,
2488            DecisionState::Recorded,
2489        ];
2490        // Confidences chosen so iteration order is deterministic and the
2491        // returned sort order is the descending index 99 -> 50.
2492        for i in 0..100u32 {
2493            let desc = format!("convention #{i:03}");
2494            // Confidence decreases as i increases — ORDER BY confidence DESC
2495            // sorts undecided ascending by index in the result.
2496            let confidence = 1.0 - (i as f64) / 1000.0;
2497            let hash = seed_auto_convention(&conn, "main", &desc, confidence);
2498            if i < 50 {
2499                seed_decision_for_hash(&conn, "main", &desc, &hash, states[(i as usize) % 4]);
2500            }
2501        }
2502
2503        let (items, _) = query_conventions_for_review(&conn, "main").unwrap();
2504        assert_eq!(
2505            items.len(),
2506            50,
2507            "expected exactly 50 undecided rows; got {}",
2508            items.len()
2509        );
2510
2511        // The returned descriptions must be exactly indices 50..=99.
2512        let mut returned: Vec<u32> = items
2513            .iter()
2514            .map(|c| {
2515                c.description
2516                    .trim_start_matches("convention #")
2517                    .parse::<u32>()
2518                    .expect("parseable index")
2519            })
2520            .collect();
2521        returned.sort_unstable();
2522        let expected: Vec<u32> = (50..100).collect();
2523        assert_eq!(
2524            returned, expected,
2525            "returned set must be exactly the undecided indices 50..=99"
2526        );
2527    }
2528
2529    #[test]
2530    fn query_conventions_for_review_uses_index_on_decisions() {
2531        // AC: EXPLAIN QUERY PLAN must show indexed access on decisions
2532        // (i.e. SEARCH ... USING INDEX, never SCAN decisions).
2533        let conn = open_test_db();
2534        let g = lock_conn(&conn).unwrap();
2535
2536        // Same SQL as the production path, kept inline so the assertion
2537        // tracks any change to the actual query.
2538        let sql = format!(
2539            "EXPLAIN QUERY PLAN
2540             SELECT n.id, n.description, n.nature, n.weight, n.confidence,
2541                    n.adoption_count, n.total_count, n.ext_data, n.description_hash
2542             FROM nodes n
2543             LEFT JOIN decisions d ON d.description_hash = n.description_hash
2544             WHERE n.branch_id = ?1
2545               AND n.nature IN ('convention', 'observation')
2546               AND {sql_not_removed}
2547               AND d.description_hash IS NULL
2548             ORDER BY n.confidence DESC",
2549            sql_not_removed = SQL_NOT_REMOVED
2550        );
2551        let mut stmt = g.prepare(&sql).unwrap();
2552        let plan_rows: Vec<String> = stmt
2553            .query_map(params!["main"], |row| row.get::<_, String>(3))
2554            .unwrap()
2555            .map(|r| r.unwrap())
2556            .collect();
2557        let plan = plan_rows.join("\n");
2558
2559        // The decisions side of the LEFT JOIN must use indexed access.
2560        // SQLite spells this "SEARCH d USING COVERING INDEX
2561        // sqlite_autoindex_decisions_1 (description_hash=?) LEFT-JOIN" —
2562        // the alias `d` is used in the plan because the FROM clause aliased
2563        // `decisions` as `d`. The PK auto-index is what keeps this lookup
2564        // O(log N) instead of O(N).
2565        assert!(
2566            plan.contains("sqlite_autoindex_decisions_1"),
2567            "expected the decisions PK auto-index in the plan; got: {plan}"
2568        );
2569        assert!(
2570            !plan.contains("SCAN d ") && !plan.contains("SCAN decisions"),
2571            "decisions side of join should be searched via index, not scanned; got: {plan}"
2572        );
2573        assert!(
2574            plan.contains("SEARCH d "),
2575            "expected SEARCH d (decisions alias) in plan; got: {plan}"
2576        );
2577    }
2578
2579    #[test]
2580    fn query_conventions_for_review_keeps_undecided_with_null_hash() {
2581        // Defensive: an auto-detected node whose description_hash is NULL
2582        // (legacy pre-V8 row, or a row inserted before US-008 backfilled
2583        // hashes) must NOT be filtered out — there's no decision to match
2584        // against, so it stays in the queue. SQL semantics: the LEFT JOIN's
2585        // ON condition `d.description_hash = NULL` is unknown for any row,
2586        // so the join produces NULL on the decisions side, and the WHERE
2587        // `d.description_hash IS NULL` predicate keeps the node.
2588        let conn = open_test_db();
2589        {
2590            let g = lock_conn(&conn).unwrap();
2591            g.execute(
2592                "INSERT INTO nodes
2593                     (branch_id, nature, weight, confidence,
2594                      adoption_count, total_count, description, ext_data, description_hash)
2595                 VALUES ('main', 'convention', 'strong', 0.9, 5, 5,
2596                         'legacy null-hash node',
2597                         json('{\"source\":\"auto_detected\"}'), NULL)",
2598                [],
2599            )
2600            .unwrap();
2601        }
2602        let (items, _) = query_conventions_for_review(&conn, "main").unwrap();
2603        assert_eq!(items.len(), 1);
2604        assert_eq!(items[0].description, "legacy null-hash node");
2605        assert!(items[0].description_hash.is_none());
2606    }
2607}