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 pub snapshot_hash: u64,
30 pub example_index: usize,
32 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 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 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 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
410pub 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 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 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 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 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 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 pub total_in_scope: usize,
734 pub already_confirmed: usize,
738}
739
740pub 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 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 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 app.mark_acted_on(0);
1173 app.advance_to_next_unreviewed();
1174 assert_eq!(app.current_index, 1);
1175 assert!(!app.quit);
1176
1177 app.mark_acted_on(1);
1179 app.advance_to_next_unreviewed();
1180 assert_eq!(app.current_index, 2);
1181 assert!(!app.quit);
1182
1183 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 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 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 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 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 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 #[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 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 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 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 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 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 assert_eq!(app.search_query, "error");
1922 assert!(app.filtered_total() >= 1);
1923 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 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 for c in "zzzzzzzz".chars() {
1966 app.push_search_char(c);
1967 }
1968 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 #[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 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 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 assert_eq!(app.current().unwrap().example_index, 0);
2068 app.previous();
2069 assert_eq!(app.current().unwrap().example_index, 0);
2070 }
2071
2072 #[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 #[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 #[test]
2172 fn show_summary_runs_all_branches() {
2173 show_summary(
2175 &[],
2176 &SummaryContext {
2177 total_in_scope: 0,
2178 already_confirmed: 0,
2179 },
2180 );
2181
2182 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 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 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 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 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 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 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 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 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 assert_eq!(count_confirmed_conventions(&conn), 5);
2338 }
2339
2340 #[test]
2341 fn count_confirmed_conventions_ignores_branch_filter() {
2342 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 assert_eq!(count_confirmed_conventions(&conn), 3);
2368 }
2369
2370 #[test]
2371 fn count_confirmed_conventions_handles_large_count() {
2372 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 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 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 let conn = open_test_db();
2484 let states = [
2485 DecisionState::Approved,
2486 DecisionState::Rejected,
2487 DecisionState::Partial,
2488 DecisionState::Recorded,
2489 ];
2490 for i in 0..100u32 {
2493 let desc = format!("convention #{i:03}");
2494 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 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 let conn = open_test_db();
2534 let g = lock_conn(&conn).unwrap();
2535
2536 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 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 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}