use std::collections::HashSet;
use std::io::{self, IsTerminal};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use clap::Args;
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, MultiSelect};
use indicatif::{ProgressBar, ProgressStyle};
use mati_core::analysis::blast_radius::BlastTier;
use mati_core::health::quality;
use mati_core::store::{FileRecord, GotchaRecord, Record, RecordLifecycle};
use super::colors;
#[derive(Args)]
#[command(
long_about = "Maintenance: confirm auto-detected gotcha candidates for hook enforcement.\n\
Candidates are generated by `mati init` from git history and code patterns.\n\
Confirmed gotchas are injected by hooks when Claude reads the affected file."
)]
pub struct ReviewArgs {
#[arg(long)]
pub triage: bool,
#[arg(long, value_name = "TYPE")]
pub r#type: Option<String>,
#[arg(value_name = "FILE")]
pub file: Option<String>,
#[arg(long, value_name = "SCORE")]
pub min_quality: Option<f32>,
#[arg(long)]
pub hotspot: bool,
}
#[derive(Debug, Clone, PartialEq)]
enum CandidateType {
Ownership,
CoChange,
Revert,
Other,
}
impl CandidateType {
fn from_tags(tags: &[String]) -> Self {
for t in tags {
match t.as_str() {
"ownership" => return Self::Ownership,
"co-change" => return Self::CoChange,
"revert" => return Self::Revert,
_ => {}
}
}
Self::Other
}
fn label(&self) -> &'static str {
match self {
Self::Ownership => "ownership",
Self::CoChange => "co-change",
Self::Revert => "revert",
Self::Other => "gotcha",
}
}
fn description(&self) -> &'static str {
match self {
Self::Ownership => "single-author hotspot files — knowledge silo risk",
Self::CoChange => "file pairs that always change together",
Self::Revert => "files with elevated revert rate",
Self::Other => "auto-detected gotcha candidates",
}
}
}
struct SessionStats {
confirmed: usize,
deleted: usize,
skipped: usize,
total: usize,
}
pub async fn run(args: ReviewArgs) -> Result<()> {
if !io::stdout().is_terminal() {
eprintln!("mati review requires an interactive terminal.");
eprintln!("Use `mati gotcha edit <key>` to edit a specific record directly.");
return Ok(());
}
let cwd = std::env::current_dir()?;
let proxy = crate::cli::proxy::StoreProxy::open(&cwd).await?;
let candidates_result = collect_candidates(&proxy).await;
let hotspot_files = if args.hotspot {
collect_hotspot_files(&proxy).await
} else {
std::collections::HashSet::new()
};
let blast_scores: std::collections::HashMap<String, (f32, BlastTier)> = {
let mut scores = std::collections::HashMap::new();
if let Ok(file_recs) = proxy.scan_prefix("file:").await {
for rec in file_recs {
if let Some(fr) = rec.payload_as::<FileRecord>() {
if let Some(ref br) = fr.blast_radius {
scores.insert(fr.path.clone(), (br.score, br.tier));
}
}
}
}
scores
};
let mut candidates = proxy.close_with_result(candidates_result).await?;
if let Some(ref type_filter) = args.r#type {
candidates.retain(|r| r.tags.iter().any(|t| t == type_filter));
}
if let Some(ref file_arg) = args.file {
let needle = file_arg.trim_start_matches("file:");
candidates.retain(|r| {
r.payload_as::<GotchaRecord>()
.map(|g| g.affected_files.iter().any(|f| f.contains(needle)))
.unwrap_or(false)
});
}
if let Some(min_q) = args.min_quality {
candidates.retain(|r| r.quality.value >= min_q);
}
if args.hotspot {
candidates.retain(|r| is_hotspot_linked(r, &hotspot_files));
}
if candidates.is_empty() {
println!("\nNo candidates pending review.");
if args.r#type.is_some()
|| args.file.is_some()
|| args.min_quality.is_some()
|| args.hotspot
{
println!(" tip: try `mati review` without filters to see all candidates.");
} else {
println!("Run `mati init` to generate candidates from git history.");
println!("Run `mati gaps` to see what else needs attention.");
}
return Ok(());
}
let summary = backlog_summary(&candidates);
if args.triage {
run_triage_mode(candidates, &cwd, &summary, &blast_scores).await
} else {
run_card_mode(candidates, &cwd, &summary).await
}
}
struct BacklogSummary {
total: usize,
on_hotspots: usize,
oldest_days: u64,
}
fn backlog_summary(candidates: &[Record]) -> BacklogSummary {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let oldest_created = candidates.iter().map(|r| r.created_at).min().unwrap_or(now);
let oldest_days = (now.saturating_sub(oldest_created)) / 86400;
let on_hotspots = candidates
.iter()
.filter(|r| r.tags.iter().any(|t| t == "hotspot"))
.count();
BacklogSummary {
total: candidates.len(),
on_hotspots,
oldest_days,
}
}
async fn run_triage_mode(
candidates: Vec<Record>,
cwd: &Path,
summary: &BacklogSummary,
blast_scores: &std::collections::HashMap<String, (f32, BlastTier)>,
) -> Result<()> {
let theme = ColorfulTheme::default();
println!("\n◈ mati review — triage mode\n");
print_backlog_summary(summary);
let mut groups = group_by_type(&candidates);
for group in groups.values_mut() {
group.sort_by(|a, b| {
let score_a = max_blast_score(a, blast_scores);
let score_b = max_blast_score(b, blast_scores);
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
let group_order = [
CandidateType::CoChange,
CandidateType::Ownership,
CandidateType::Revert,
CandidateType::Other,
];
println!(" {:<14} {:<8} description", "type", "count");
println!(
" {} {} {}",
"─".repeat(14),
"─".repeat(8),
"─".repeat(44)
);
for kind in &group_order {
let count = groups.get(kind.label()).map(|v| v.len()).unwrap_or(0);
if count > 0 {
println!(
" {:<14} {:<8} {}",
kind.label(),
count,
kind.description()
);
}
}
println!();
let mut session = SessionStats {
confirmed: 0,
deleted: 0,
skipped: 0,
total: candidates.len(),
};
loop {
let mut group_options = vec!["── quit ──".to_string()];
group_options.extend(group_order.iter().filter_map(|kind| {
let count = groups.get(kind.label()).map(|v| v.len()).unwrap_or(0);
if count > 0 {
Some(format!(
"{:<14} {} candidate{}",
kind.label(),
count,
if count == 1 { "" } else { "s" }
))
} else {
None
}
}));
let sel = Select::new(&theme)
.with_prompt("select group [Esc to quit]")
.items(&group_options)
.default(1)
.interact()
.unwrap_or_default();
if sel == 0 {
break;
}
let selected_kind = group_order
.iter()
.filter(|k| {
groups
.get(k.label())
.map(|v| !v.is_empty())
.unwrap_or(false)
})
.nth(sel - 1)
.expect(
"dialoguer sel is bounded by the same filtered group set used to build the menu",
);
let group_candidates = groups
.get(selected_kind.label())
.expect("selected_kind came from the filter that requires this group to exist");
triage_group(
group_candidates,
selected_kind,
cwd,
&theme,
&mut session,
blast_scores,
)
.await?;
println!();
}
print_session_summary(&session);
Ok(())
}
async fn triage_group(
candidates: &[&Record],
kind: &CandidateType,
cwd: &Path,
theme: &ColorfulTheme,
session: &mut SessionStats,
blast_scores: &std::collections::HashMap<String, (f32, BlastTier)>,
) -> Result<()> {
println!(
"\n ◈ {} — {} candidate{}\n",
kind.label(),
candidates.len(),
if candidates.len() == 1 { "" } else { "s" }
);
let action_options = [
format!(
"accept all confirm all {} above quality 0.40",
candidates.len()
),
"review each step through one by one".to_string(),
"skip group come back later".to_string(),
];
let action = Select::new(theme)
.with_prompt("action")
.items(&action_options)
.default(0)
.interact()?;
match action {
0 => {
let items: Vec<String> = candidates
.iter()
.map(|r| format_list_item_with_blast(r, blast_scores))
.collect();
let defaults = vec![true; items.len()];
println!("\n Space to deselect · Enter to confirm selection\n");
let selected_indices = MultiSelect::with_theme(theme)
.with_prompt("confirm candidates")
.items(&items)
.defaults(&defaults)
.interact()?;
if selected_indices.is_empty() {
println!(" No candidates selected.");
return Ok(());
}
let keys_to_confirm: Vec<String> = selected_indices
.iter()
.map(|&i| candidates[i].key.clone())
.collect();
let pb = ProgressBar::new(keys_to_confirm.len() as u64);
pb.set_style(
ProgressStyle::with_template(" [{bar:40.green}] {pos}/{len} confirmed")
.expect("static template literal — only fails on programmer error in the format string")
.progress_chars("█░░"),
);
bulk_confirm_candidates(cwd, &keys_to_confirm, &pb).await?;
pb.finish_and_clear();
println!(
"\n ✓ {} confirmed — eligible for hook injection.",
keys_to_confirm.len()
);
session.confirmed += keys_to_confirm.len();
let skipped = candidates.len() - keys_to_confirm.len();
if skipped > 0 {
session.skipped += skipped;
}
}
1 => {
let group_vec: Vec<Record> = candidates.iter().map(|r| (*r).clone()).collect();
let stats = run_card_session(group_vec, cwd, theme).await?;
session.confirmed += stats.confirmed;
session.deleted += stats.deleted;
session.skipped += stats.skipped;
}
_ => {
session.skipped += candidates.len();
println!(" Group skipped.");
}
}
Ok(())
}
async fn run_card_mode(
candidates: Vec<Record>,
cwd: &Path,
summary: &BacklogSummary,
) -> Result<()> {
let theme = ColorfulTheme::default();
println!("\n◈ mati review\n");
print_backlog_summary(summary);
println!(" Type to filter · ↑↓ to navigate · Enter to select\n");
let stats = run_card_session(candidates, cwd, &theme).await?;
print_session_summary(&stats);
Ok(())
}
async fn run_card_session(
candidates: Vec<Record>,
cwd: &Path,
theme: &ColorfulTheme,
) -> Result<SessionStats> {
let remaining: Vec<Record> = candidates;
let total = remaining.len();
let mut actioned: HashSet<String> = HashSet::new();
let mut stats = SessionStats {
confirmed: 0,
deleted: 0,
skipped: 0,
total,
};
loop {
let visible: Vec<&Record> = remaining
.iter()
.filter(|r| !actioned.contains(&r.key))
.collect();
if visible.is_empty() {
break;
}
let mut items = vec!["── quit ──".to_string()];
items.extend(visible.iter().map(|r| format_list_item(r)));
let sel = FuzzySelect::with_theme(theme)
.with_prompt(format!("{} remaining [Esc / q to quit]", visible.len()))
.items(&items)
.default(1)
.interact()
.unwrap_or_default();
if sel == 0 {
stats.skipped += visible.len();
break;
}
let record = visible[sel - 1].clone();
let key = record.key.clone();
println!();
render_card(&record);
println!();
let action_items = [
"confirm mark as correct — eligible for injection",
"edit fix the rule or reason",
"skip remove from this session",
"delete remove permanently",
"← back return to list",
];
let action = Select::with_theme(theme)
.with_prompt("action")
.items(&action_items)
.default(0)
.interact()?;
println!();
match action {
0 => {
confirm_candidate(cwd, &key).await?;
println!(" ✓ Confirmed. Eligible for hook injection.");
actioned.insert(key);
stats.confirmed += 1;
}
1 => {
if let Some(g) = record.payload_as::<GotchaRecord>() {
let confirmed = edit_candidate(cwd, &key, &g, theme).await?;
if confirmed {
stats.confirmed += 1;
}
actioned.insert(key);
} else {
println!(
" Cannot edit: not a GotchaRecord. Use `mati gotcha edit {key}` instead."
);
actioned.insert(key);
stats.skipped += 1;
}
}
2 => {
actioned.insert(key);
stats.skipped += 1;
}
3 => {
delete_candidate(cwd, &key).await?;
println!(" Deleted.");
actioned.insert(key);
stats.deleted += 1;
}
_ => {
}
}
println!();
}
Ok(stats)
}
fn render_card(record: &Record) {
let use_color = io::stdout().is_terminal();
let (cyan, yellow, green, gray, bold, reset) = if use_color {
(
colors::CYAN,
colors::YELLOW,
colors::GREEN,
colors::GRAY,
colors::BOLD,
colors::RESET,
)
} else {
("", "", "", "", "", "")
};
let kind = CandidateType::from_tags(&record.tags);
let risk = record.confidence.value;
let risk_color = if risk >= 0.7 {
colors::RED
} else if risk >= 0.4 {
yellow
} else {
gray
};
let risk_color = if use_color { risk_color } else { "" };
let width = 62usize;
let border = "─".repeat(width);
println!(" ╭{}╮", border);
let header = format!(
" {} · risk {} · quality {:.2}",
kind.label(),
format_risk(risk),
record.quality.value
);
println!(
" │ {bold}{}{reset}{}",
header,
pad_right(&header, width - 2)
);
println!(" ├{}┤", border);
if let Some(g) = record.payload_as::<GotchaRecord>() {
let rule_lines = wrap_text(&g.rule, width - 12);
for (i, line) in rule_lines.iter().enumerate() {
if i == 0 {
println!(
" │ {bold}Rule{reset} {cyan}{}{reset}{}",
line,
pad_right(line, width - 12)
);
} else {
println!(
" │ {cyan}{}{reset}{}",
line,
pad_right(line, width - 11)
);
}
}
if !g.reason.is_empty() {
let reason_lines = wrap_text(&g.reason, width - 12);
for (i, line) in reason_lines.iter().enumerate() {
if i == 0 {
println!(
" │ {bold}Reason{reset} {}{}",
line,
pad_right(line, width - 12)
);
} else {
println!(" │ {}{}", line, pad_right(line, width - 11));
}
}
}
if !g.affected_files.is_empty() {
for (i, f) in g.affected_files.iter().enumerate() {
if i == 0 {
println!(
" │ {bold}Files{reset} {green}{}{reset}{}",
f,
pad_right(f, width - 12)
);
} else {
println!(
" │ {green}{}{reset}{}",
f,
pad_right(f, width - 11)
);
}
}
}
} else if !record.value.is_empty() {
let lines = wrap_text(&record.value, width - 6);
for line in &lines {
println!(" │ {}{}", line, pad_right(line, width - 4));
}
}
println!(" ├{}┤", border);
let footer = format!(
"confidence {:.2} · access count {} · {}",
record.confidence.value,
record.access_count,
tags_display(&record.tags)
);
println!(
" │ {gray}{}{reset}{}",
footer,
pad_right(&footer, width - 2)
);
println!(" ╰{}╯", border);
let _ = (risk_color, green, cyan); }
fn format_risk(risk: f32) -> String {
format!("{:.2}", risk)
}
fn tags_display(tags: &[String]) -> String {
let relevant: Vec<&str> = tags
.iter()
.filter(|t| {
!matches!(
t.as_str(),
"ownership" | "co-change" | "revert" | "auto-generated"
)
})
.map(|t| t.as_str())
.collect();
if relevant.is_empty() {
"auto-generated".to_string()
} else {
relevant.join(", ")
}
}
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if text.len() <= max_width {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= max_width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current.clone());
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn pad_right(text: &str, width: usize) -> String {
let len = text.chars().count();
if len < width {
" ".repeat(width - len)
} else {
String::new()
}
}
pub fn format_list_item(record: &Record) -> String {
let kind = CandidateType::from_tags(&record.tags);
let risk = record.confidence.value;
let desc = if let Some(g) = record.payload_as::<GotchaRecord>() {
match kind {
CandidateType::CoChange if g.affected_files.len() >= 2 => {
let a = file_name(&g.affected_files[0]);
let b = file_name(&g.affected_files[1]);
format!("{a} ↔ {b}")
}
CandidateType::Ownership if !g.affected_files.is_empty() => {
format!(
"{} — {}",
file_name(&g.affected_files[0]),
truncate(&g.rule, 36)
)
}
_ => truncate(&g.rule, 50),
}
} else {
truncate(&record.value, 50)
};
format!("{:<12} {:.2} {}", kind.label(), risk, desc)
}
fn format_list_item_with_blast(
record: &Record,
blast_scores: &std::collections::HashMap<String, (f32, BlastTier)>,
) -> String {
let base = format_list_item(record);
match max_blast_tier(record, blast_scores) {
Some(BlastTier::Critical) => format!("[BLAST:critical] {base}"),
Some(BlastTier::High) => format!("[BLAST:high] {base}"),
_ => base,
}
}
fn max_blast_score(
record: &Record,
blast_scores: &std::collections::HashMap<String, (f32, BlastTier)>,
) -> f32 {
record
.payload_as::<GotchaRecord>()
.map(|g| {
g.affected_files
.iter()
.filter_map(|path| blast_scores.get(path).map(|(score, _)| *score))
.fold(0.0_f32, f32::max)
})
.unwrap_or(0.0)
}
fn max_blast_tier(
record: &Record,
blast_scores: &std::collections::HashMap<String, (f32, BlastTier)>,
) -> Option<BlastTier> {
record.payload_as::<GotchaRecord>().and_then(|g| {
g.affected_files
.iter()
.filter_map(|path| blast_scores.get(path).map(|(_, tier)| *tier))
.max_by_key(|tier| match tier {
BlastTier::Isolated => 0,
BlastTier::Low => 1,
BlastTier::Moderate => 2,
BlastTier::High => 3,
BlastTier::Critical => 4,
})
})
}
fn file_name(path: &str) -> &str {
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
}
async fn edit_candidate(
cwd: &Path,
key: &str,
gotcha: &GotchaRecord,
theme: &ColorfulTheme,
) -> Result<bool> {
let rule: String = Input::with_theme(theme)
.with_prompt("Rule")
.with_initial_text(&gotcha.rule)
.interact_text()?;
let reason: String = Input::with_theme(theme)
.with_prompt("Reason")
.with_initial_text(&gotcha.reason)
.allow_empty(true)
.interact_text()?;
let proxy = crate::cli::proxy::StoreProxy::open(cwd).await?;
let write_result = async {
let mut record = proxy
.get(key)
.await?
.ok_or_else(|| anyhow::anyhow!("record not found: {key}"))?;
let now = now_secs();
let updated = GotchaRecord {
rule: rule.clone(),
reason: reason.clone(),
severity: gotcha.severity.clone(),
affected_files: gotcha.affected_files.clone(),
ref_url: gotcha.ref_url.clone(),
discovered_session: gotcha.discovered_session,
confirmed: false,
};
record.value = if reason.is_empty() {
rule
} else {
format!("{} because {}", updated.rule, updated.reason)
};
record.payload = serde_json::to_value(&updated).ok();
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
record.quality = quality::analyze(&record);
proxy.put(key, &record).await
}
.await;
proxy.close_with_result(write_result).await?;
println!(" Edits saved.");
let confirm_now = Confirm::with_theme(theme)
.with_prompt("Confirm now?")
.default(true)
.interact()?;
if confirm_now {
confirm_candidate(cwd, key).await?;
println!(" ✓ Edited and confirmed.");
Ok(true)
} else {
println!(" Saved as candidate. Will reappear in next `mati review`.");
Ok(false)
}
}
fn group_by_type<'a>(
candidates: &'a [Record],
) -> std::collections::HashMap<&'static str, Vec<&'a Record>> {
let mut groups: std::collections::HashMap<&'static str, Vec<&'a Record>> =
std::collections::HashMap::new();
for record in candidates {
let kind = CandidateType::from_tags(&record.tags);
groups.entry(kind.label()).or_default().push(record);
}
groups
}
fn print_backlog_summary(summary: &BacklogSummary) {
let use_color = io::stdout().is_terminal();
let (yellow, gray, white, bold, reset) = if use_color {
(
colors::YELLOW,
colors::GRAY,
colors::WHITE,
colors::BOLD,
colors::RESET,
)
} else {
("", "", "", "", "")
};
let hotspot_hint = if summary.on_hotspots > 0 {
format!(" {yellow}{} on hotspots{reset}", summary.on_hotspots)
} else {
String::new()
};
let age_hint = if summary.oldest_days > 0 {
format!(" {gray}oldest: {}d{reset}", summary.oldest_days)
} else {
String::new()
};
println!(
" {bold}{white}{}{reset} pending{hotspot_hint}{age_hint}",
summary.total
);
println!();
}
fn print_session_summary(stats: &SessionStats) {
let use_color = io::stdout().is_terminal();
let (green, yellow, gray, bold, reset) = if use_color {
(
colors::GREEN,
colors::YELLOW,
colors::GRAY,
colors::BOLD,
colors::RESET,
)
} else {
("", "", "", "", "")
};
let remaining = stats
.total
.saturating_sub(stats.confirmed + stats.deleted + stats.skipped);
println!("\n◈ review session complete\n");
println!(
" {green}✓ confirmed{reset} {bold}{}{reset}",
stats.confirmed
);
println!(
" {yellow}↷ skipped{reset} {bold}{}{reset}",
stats.skipped
);
println!(
" {gray}✕ deleted{reset} {bold}{}{reset}",
stats.deleted
);
if remaining > 0 {
println!(" {gray}— remaining{reset} {bold}{}{reset}", remaining);
println!("\n run {gray}mati review{reset} to continue");
}
println!();
}
struct Select;
#[allow(clippy::new_ret_no_self)]
impl Select {
fn new(theme: &ColorfulTheme) -> dialoguer::Select<'_> {
dialoguer::Select::with_theme(theme)
}
fn with_theme(theme: &ColorfulTheme) -> dialoguer::Select<'_> {
dialoguer::Select::with_theme(theme)
}
}
async fn collect_candidates(store: &crate::cli::proxy::StoreProxy) -> Result<Vec<Record>> {
let all = store.scan_prefix("gotcha:").await?;
let mut candidates: Vec<Record> = all
.into_iter()
.filter(|r| {
if !matches!(r.lifecycle, RecordLifecycle::Active) {
return false;
}
match r.payload_as::<GotchaRecord>() {
Some(g) => !g.confirmed,
None => false,
}
})
.collect();
let hotspot_files = collect_hotspot_files(store).await;
candidates.sort_by(|a, b| {
let a_hotspot = is_hotspot_linked(a, &hotspot_files);
let b_hotspot = is_hotspot_linked(b, &hotspot_files);
match (a_hotspot, b_hotspot) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {}
}
let risk_a = a.confidence.value * (1.0 + a.access_count as f32 * 0.1);
let risk_b = b.confidence.value * (1.0 + b.access_count as f32 * 0.1);
risk_b
.partial_cmp(&risk_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(candidates)
}
async fn collect_hotspot_files(
store: &crate::cli::proxy::StoreProxy,
) -> std::collections::HashSet<String> {
let mut hotspots = std::collections::HashSet::new();
if let Ok(files) = store.scan_prefix("file:").await {
for f in files {
if let Some(fr) = f.payload_as::<FileRecord>() {
if fr.is_hotspot {
hotspots.insert(fr.path);
}
}
}
}
hotspots
}
fn is_hotspot_linked(record: &Record, hotspot_files: &std::collections::HashSet<String>) -> bool {
record
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files.iter().any(|f| hotspot_files.contains(f)))
.unwrap_or(false)
}
async fn bulk_confirm_candidates(cwd: &Path, keys: &[String], pb: &ProgressBar) -> Result<()> {
let proxy = crate::cli::proxy::StoreProxy::open(cwd).await?;
for key in keys {
if let Err(e) = proxy.gotcha_confirm(key).await {
tracing::warn!("bulk confirm {key}: {e}");
}
pb.inc(1);
}
proxy.close().await?;
Ok(())
}
async fn confirm_candidate(cwd: &Path, key: &str) -> Result<()> {
let proxy = crate::cli::proxy::StoreProxy::open(cwd).await?;
let result = proxy.gotcha_confirm(key).await;
proxy.close_with_result(result).await
}
async fn delete_candidate(cwd: &Path, key: &str) -> Result<()> {
let proxy = crate::cli::proxy::StoreProxy::open(cwd).await?;
let result = async {
let affected_files = proxy
.get(key)
.await?
.and_then(|r| r.payload_as::<GotchaRecord>())
.map(|g| g.affected_files)
.unwrap_or_default();
proxy.gotcha_tombstone(key, &affected_files).await
}
.await;
proxy.close_with_result(result).await
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() > max {
let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{truncated}…")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use mati_core::store::{
Category, ConfidenceScore, Priority, QualityScore, QualityTier, RecordSource,
RecordVersion, StalenessScore,
};
fn make_gotcha(
confirmed: bool,
tags: &[&str],
files: &[&str],
rule: &str,
) -> (GotchaRecord, Record) {
let gotcha = GotchaRecord {
rule: rule.to_string(),
reason: "Test reason.".to_string(),
severity: Priority::Normal,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: 0,
confirmed,
};
let now = 1_710_520_800u64;
let record = Record {
key: format!("gotcha:{}", rule.to_lowercase().replace(' ', "-")),
value: rule.to_string(),
payload: serde_json::to_value(&gotcha).ok(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: tags.iter().map(|s| s.to_string()).collect(),
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: now,
},
quality: QualityScore {
value: 0.40,
tier: QualityTier::Acceptable,
signals: vec![],
computed_at: 0,
},
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore {
value: 0.40,
confirmation_count: 0,
contributor_count: 1,
last_challenged: None,
challenge_count: 0,
},
gap_analysis_score: 0.0,
};
(gotcha, record)
}
#[test]
fn collect_candidates_filters_confirmed() {
let (_, confirmed) = make_gotcha(true, &["co-change"], &["src/a.rs"], "Already confirmed");
let (g, _) = make_gotcha(true, &[], &[], "");
assert!(g.confirmed);
let (_, candidate) = make_gotcha(false, &["co-change"], &["src/b.rs"], "Needs review");
let (g2, _) = make_gotcha(false, &[], &[], "");
assert!(!g2.confirmed);
let confirmed_payload: GotchaRecord = confirmed.payload_as().unwrap();
assert!(confirmed_payload.confirmed);
let candidate_payload: GotchaRecord = candidate.payload_as().unwrap();
assert!(!candidate_payload.confirmed);
}
#[test]
fn collect_candidates_includes_low_quality() {
let (_, low_quality) =
make_gotcha(false, &["co-change"], &["src/c.rs"], "Low quality stub");
assert!(low_quality.quality.value >= 0.0, "quality present");
let payload: GotchaRecord = low_quality.payload_as().unwrap();
assert!(!payload.confirmed);
}
#[test]
fn collect_candidates_sorts_by_risk() {
let (_, mut low) = make_gotcha(false, &["co-change"], &["src/low.rs"], "Low risk");
low.confidence.value = 0.20;
low.access_count = 0;
let (_, mut high) = make_gotcha(false, &["co-change"], &["src/high.rs"], "High risk");
high.confidence.value = 0.80;
high.access_count = 10;
let mut candidates = [low, high];
candidates.sort_by(|a, b| {
let risk_a = a.confidence.value * (1.0 + a.access_count as f32 * 0.1);
let risk_b = b.confidence.value * (1.0 + b.access_count as f32 * 0.1);
risk_b
.partial_cmp(&risk_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
assert!(candidates[0].key.contains("high"));
}
#[test]
fn truncate_at_boundary() {
assert_eq!(truncate("hello", 10), "hello");
let result = truncate("this is a very long string", 10);
assert!(result.ends_with('…'));
assert!(result.chars().count() <= 10);
}
#[test]
fn truncate_exact_length() {
assert_eq!(truncate("exactlen!", 9), "exactlen!");
}
#[test]
fn format_list_item_co_change() {
let (_, record) = make_gotcha(
false,
&["co-change", "auto-generated"],
&["src/server.rs", "src/config.rs"],
"server.rs always changes with config.rs",
);
let item = format_list_item(&record);
assert!(item.contains("co-change"));
assert!(item.contains("server.rs"));
assert!(item.contains("config.rs"));
}
#[test]
fn format_list_item_ownership() {
let (_, record) = make_gotcha(
false,
&["ownership", "auto-generated"],
&["src/pipeline.rs"],
"100% of commits by ioni_dev",
);
let item = format_list_item(&record);
assert!(item.contains("ownership"));
assert!(item.contains("pipeline.rs"));
}
#[test]
fn candidate_type_from_tags() {
assert_eq!(
CandidateType::from_tags(&["co-change".to_string(), "auto-generated".to_string()]),
CandidateType::CoChange
);
assert_eq!(
CandidateType::from_tags(&["ownership".to_string()]),
CandidateType::Ownership
);
assert_eq!(
CandidateType::from_tags(&["revert".to_string()]),
CandidateType::Revert
);
assert_eq!(
CandidateType::from_tags(&["something-else".to_string()]),
CandidateType::Other
);
}
#[test]
fn group_by_type_groups_correctly() {
let (_, r1) = make_gotcha(false, &["co-change"], &["a.rs", "b.rs"], "Co-change rule");
let (_, r2) = make_gotcha(false, &["ownership"], &["c.rs"], "Ownership rule");
let (_, r3) = make_gotcha(
false,
&["co-change"],
&["d.rs", "e.rs"],
"Another co-change",
);
let records = vec![r1, r2, r3];
let groups = group_by_type(&records);
assert_eq!(groups.get("co-change").map(|v| v.len()), Some(2));
assert_eq!(groups.get("ownership").map(|v| v.len()), Some(1));
assert!(!groups.contains_key("revert"));
}
#[test]
fn wrap_text_short() {
let lines = wrap_text("short text", 50);
assert_eq!(lines, vec!["short text"]);
}
#[test]
fn wrap_text_wraps_at_word_boundary() {
let lines = wrap_text("one two three four five", 12);
assert!(lines.len() > 1);
for line in &lines {
assert!(line.len() <= 12, "line too long: {line:?}");
}
}
}