- 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
126 lines
3.5 KiB
Rust
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);
|
|
}
|
|
}
|