llm_kernel/graph/
dedup.rs1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::lifecycle::days_to_ymd;
8use super::store::upsert_node;
9use super::types::GraphNode;
10
11pub fn upsert_node_dedup(
17 conn: &Connection,
18 node: &GraphNode,
19 window_hours: u64,
20) -> Result<(String, bool)> {
21 if let Some(existing_id) = find_duplicate(conn, &node.title, window_hours)? {
22 return Ok((existing_id, true));
23 }
24 upsert_node(conn, node)?;
25 Ok((node.id.clone(), false))
26}
27
28pub fn find_duplicate(conn: &Connection, title: &str, window_hours: u64) -> Result<Option<String>> {
30 let cutoff = compute_cutoff_timestamp(window_hours);
31 let result = conn.query_row(
32 "SELECT id FROM nodes WHERE title = ?1 AND updated > ?2 ORDER BY updated DESC LIMIT 1",
33 params![title, cutoff],
34 |row| row.get::<_, String>(0),
35 );
36 match result {
37 Ok(id) => Ok(Some(id)),
38 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
39 Err(e) => Err(KernelError::Store(e.to_string())),
40 }
41}
42
43fn compute_cutoff_timestamp(window_hours: u64) -> String {
44 let secs = std::time::SystemTime::now()
45 .duration_since(std::time::SystemTime::UNIX_EPOCH)
46 .unwrap_or_default()
47 .as_secs()
48 .saturating_sub(window_hours * 3600);
49 let (y, m, d) = days_to_ymd(secs / 86400);
50 let hh = (secs / 3600) % 24;
51 let mm = (secs / 60) % 60;
52 let ss = secs % 60;
53 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59 use crate::graph::schema::init_graph_schema;
60 use crate::graph::store::read_node;
61 use crate::graph::types::GraphNode;
62 use rusqlite::Connection;
63
64 fn mem_db() -> Connection {
65 let conn = Connection::open_in_memory().unwrap();
66 init_graph_schema(&conn).unwrap();
67 conn
68 }
69
70 fn now_iso() -> String {
71 let secs = std::time::SystemTime::now()
72 .duration_since(std::time::SystemTime::UNIX_EPOCH)
73 .unwrap_or_default()
74 .as_secs();
75 let (y, m, d) = days_to_ymd(secs / 86400);
76 let hh = (secs / 3600) % 24;
77 let mm = (secs / 60) % 60;
78 let ss = secs % 60;
79 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
80 }
81
82 fn test_node(id: &str, title: &str) -> GraphNode {
83 let now = now_iso();
84 GraphNode {
85 id: id.to_string(),
86 node_type: "concept".to_string(),
87 title: title.to_string(),
88 body: String::new(),
89 tags: vec![],
90 projects: vec![],
91 agents: vec![],
92 created: now.clone(),
93 updated: now,
94 importance: 0.5,
95 access_count: 0,
96 accessed_at: String::new(),
97 }
98 }
99
100 #[test]
101 fn dedup_inserts_new_node() {
102 let conn = mem_db();
103 let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n1", "My Title"), 24).unwrap();
104 assert_eq!(id, "n1");
105 assert!(!was_dup);
106 assert!(read_node(&conn, "n1").unwrap().is_some());
107 }
108
109 #[test]
110 fn dedup_returns_existing_for_same_title() {
111 let conn = mem_db();
112 upsert_node_dedup(&conn, &test_node("n1", "Same Title"), 24).unwrap();
113
114 let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Same Title"), 24).unwrap();
116 assert_eq!(id, "n1");
117 assert!(was_dup);
118
119 assert!(read_node(&conn, "n1").unwrap().is_some());
121 assert!(read_node(&conn, "n2").unwrap().is_none());
122 }
123
124 #[test]
125 fn dedup_allows_after_window_expires() {
126 let conn = mem_db();
127 let mut old_node = test_node("n1", "Old Title");
129 old_node.updated = "2020-01-01T00:00:00Z".to_string();
130 upsert_node(&conn, &old_node).unwrap();
131
132 let (id, was_dup) = upsert_node_dedup(&conn, &test_node("n2", "Old Title"), 24).unwrap();
134 assert_eq!(id, "n2");
135 assert!(!was_dup);
136 }
137
138 #[test]
139 fn find_duplicate_returns_none_when_empty() {
140 let conn = mem_db();
141 let result = find_duplicate(&conn, "nonexistent", 24).unwrap();
142 assert!(result.is_none());
143 }
144}