Files
deduper/src/ignore_db.rs
admin c039029790 feat: "Not a dupe" ignore with SQLite persistence
- New ignore_db module with SQLite-backed dismissal storage
- Groups flagged as not-a-dupe are persisted to ~/.config/deduper/ignores.db
- Fingerprint based on sorted SHA256 hashes (content-stable)
- Ignored groups filtered out on subsequent runs
- Review UI: green "Not a dupe" button per group
- Dismissed groups fade out immediately in browser
- DEDUPER_DB_DIR env var to override DB location
- 4 new unit tests for ignore_db
- 29 tests passing
2026-04-28 00:45:52 +00:00

126 lines
3.5 KiB
Rust

use anyhow::Result;
use rusqlite::Connection;
use std::path::PathBuf;
fn db_path() -> PathBuf {
let dir = dirs_or_default();
std::fs::create_dir_all(&dir).ok();
dir.join("ignores.db")
}
fn dirs_or_default() -> PathBuf {
std::env::var("DEDUPER_DB_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".config").join("deduper")
})
}
pub fn open_db() -> Result<Connection> {
let path = db_path();
let conn = Connection::open(&path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS ignored_groups (
fingerprint TEXT PRIMARY KEY,
created_at TEXT DEFAULT (datetime('now')),
note TEXT DEFAULT ''
);",
)?;
Ok(conn)
}
/// Fingerprint = sorted sha256 hashes joined by `|`
pub fn group_fingerprint(sha256s: &[&str]) -> String {
let mut sorted: Vec<&str> = sha256s.to_vec();
sorted.sort();
sorted.dedup();
sorted.join("|")
}
pub fn ignore_group(conn: &Connection, fingerprint: &str) -> Result<()> {
conn.execute(
"INSERT OR IGNORE INTO ignored_groups (fingerprint) VALUES (?1)",
[fingerprint],
)?;
Ok(())
}
pub fn is_group_ignored(conn: &Connection, fingerprint: &str) -> bool {
conn.query_row(
"SELECT 1 FROM ignored_groups WHERE fingerprint = ?1",
[fingerprint],
|_| Ok(true),
)
.unwrap_or(false)
}
pub fn remove_ignore(conn: &Connection, fingerprint: &str) -> Result<()> {
conn.execute(
"DELETE FROM ignored_groups WHERE fingerprint = ?1",
[fingerprint],
)?;
Ok(())
}
pub fn list_ignored(conn: &Connection) -> Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT fingerprint, created_at FROM ignored_groups ORDER BY created_at DESC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn open_db_in_memory() -> Result<Connection> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS ignored_groups (
fingerprint TEXT PRIMARY KEY,
created_at TEXT DEFAULT (datetime('now')),
note TEXT DEFAULT ''
);",
)?;
Ok(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ignore_and_check_group() {
let conn = open_db_in_memory().unwrap();
let fp = group_fingerprint(&["sha_b", "sha_a"]);
assert!(!is_group_ignored(&conn, &fp));
ignore_group(&conn, &fp).unwrap();
assert!(is_group_ignored(&conn, &fp));
}
#[test]
fn fingerprint_is_sorted_and_stable() {
let fp1 = group_fingerprint(&["bbb", "aaa"]);
let fp2 = group_fingerprint(&["aaa", "bbb"]);
assert_eq!(fp1, fp2);
assert_eq!(fp1, "aaa|bbb");
}
#[test]
fn remove_ignore_works() {
let conn = open_db_in_memory().unwrap();
let fp = group_fingerprint(&["x", "y"]);
ignore_group(&conn, &fp).unwrap();
assert!(is_group_ignored(&conn, &fp));
remove_ignore(&conn, &fp).unwrap();
assert!(!is_group_ignored(&conn, &fp));
}
#[test]
fn list_ignored_returns_entries() {
let conn = open_db_in_memory().unwrap();
ignore_group(&conn, "fp1").unwrap();
ignore_group(&conn, "fp2").unwrap();
let list = list_ignored(&conn).unwrap();
assert_eq!(list.len(), 2);
}
}