1use std::collections::HashMap;
4
5use rusqlite::{Connection, params};
6
7use crate::error::{KernelError, Result};
8
9use super::types::GraphStats;
10
11pub fn touch_node(conn: &Connection, id: &str) {
15 let now = now_iso();
16 let _ = conn.execute(
17 "UPDATE nodes SET access_count = access_count + 1, accessed_at = ?1 WHERE id = ?2",
18 params![now, id],
19 );
20}
21
22pub fn touch_nodes(conn: &Connection, ids: &[String]) {
24 if ids.is_empty() {
25 return;
26 }
27 let _ = conn.execute_batch("SAVEPOINT touch_batch");
28 for id in ids {
29 touch_node(conn, id);
30 }
31 let _ = conn.execute_batch("RELEASE touch_batch");
32}
33
34pub fn decay_importance(conn: &Connection, days: u64, factor: f64, floor: f64) -> Result<u64> {
44 let cutoff = compute_cutoff_timestamp(days);
45 let changed = conn
46 .execute(
47 "UPDATE nodes SET importance = MAX(?3, importance * ?2)
48 WHERE (accessed_at < ?1 OR accessed_at = '')
49 AND updated < ?1
50 AND importance > ?3
51 AND ',' || tags || ',' NOT LIKE '%,pinned,%'",
52 params![cutoff, factor, floor],
53 )
54 .map_err(|e| KernelError::Store(e.to_string()))?;
55 Ok(changed as u64)
56}
57
58pub fn tag_stale_nodes(conn: &Connection, days: u64) -> Result<u64> {
60 let cutoff = compute_cutoff_timestamp(days);
61 let changed = conn
62 .execute(
63 "UPDATE nodes SET tags = CASE
64 WHEN tags = '' THEN 'stale'
65 WHEN ',' || tags || ',' NOT LIKE '%,stale,%' THEN tags || ',stale'
66 ELSE tags
67 END
68 WHERE updated < ?1
69 AND ',' || tags || ',' NOT LIKE '%,stale,%'",
70 params![cutoff],
71 )
72 .map_err(|e| KernelError::Store(e.to_string()))?;
73 Ok(changed as u64)
74}
75
76pub fn mark_verified(conn: &Connection, id: &str, now: &str) -> Result<bool> {
82 let changed = conn
83 .execute(
84 "UPDATE nodes SET last_verified = ?1 WHERE id = ?2",
85 params![now, id],
86 )
87 .map_err(|e| KernelError::Store(e.to_string()))?;
88 Ok(changed > 0)
89}
90
91pub fn count_expired_nodes(conn: &Connection, now: &str) -> Result<u64> {
97 let n: i64 = conn
98 .query_row(
99 "SELECT COUNT(*) FROM nodes WHERE valid_until <> '' AND valid_until < ?1",
100 params![now],
101 |r| r.get(0),
102 )
103 .map_err(|e| KernelError::Store(e.to_string()))?;
104 Ok(n as u64)
105}
106
107pub fn compute_stats(conn: &Connection) -> Result<GraphStats> {
111 let total_nodes: i64 = conn
112 .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))
113 .unwrap_or(0);
114 let total_edges: i64 = conn
115 .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
116 .unwrap_or(0);
117 let avg_importance: f64 = conn
118 .query_row("SELECT AVG(importance) FROM nodes", [], |r| r.get(0))
119 .unwrap_or(0.0);
120
121 let mut stmt = conn
122 .prepare("SELECT type, COUNT(*) FROM nodes GROUP BY type")
123 .map_err(|e| KernelError::Store(e.to_string()))?;
124 let by_type: HashMap<String, i64> = stmt
125 .query_map([], |row| {
126 let t: String = row.get(0)?;
127 let c: i64 = row.get(1)?;
128 Ok((t, c))
129 })
130 .map(|rows| rows.flatten().collect())
131 .unwrap_or_default();
132
133 Ok(GraphStats {
134 total_nodes,
135 total_edges,
136 avg_importance: (avg_importance * 100.0).round() / 100.0,
137 by_type,
138 })
139}
140
141fn compute_cutoff_timestamp(days: u64) -> String {
144 let secs = std::time::SystemTime::now()
145 .duration_since(std::time::SystemTime::UNIX_EPOCH)
146 .unwrap_or_default()
147 .as_secs()
148 .saturating_sub(days * 86400);
149 let (y, m, d) = days_to_ymd(secs / 86400);
150 let hh = (secs / 3600) % 24;
151 let mm = (secs / 60) % 60;
152 let ss = secs % 60;
153 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
154}
155
156pub(crate) fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
157 let mut year = 1970u64;
158 loop {
159 let leap = is_leap(year);
160 let days_in_year = if leap { 366 } else { 365 };
161 if days < days_in_year {
162 break;
163 }
164 days -= days_in_year;
165 year += 1;
166 }
167 let leap = is_leap(year);
168 let month_days = [
169 31u64,
170 if leap { 29 } else { 28 },
171 31,
172 30,
173 31,
174 30,
175 31,
176 31,
177 30,
178 31,
179 30,
180 31,
181 ];
182 let mut month = 1u64;
183 for md in &month_days {
184 if days < *md {
185 break;
186 }
187 days -= md;
188 month += 1;
189 }
190 (year, month, days + 1)
191}
192
193fn is_leap(y: u64) -> bool {
194 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
195}
196
197pub(crate) fn now_iso() -> String {
198 let secs = std::time::SystemTime::now()
199 .duration_since(std::time::SystemTime::UNIX_EPOCH)
200 .unwrap_or_default()
201 .as_secs();
202 let (y, m, d) = days_to_ymd(secs / 86400);
203 let hh = (secs / 3600) % 24;
204 let mm = (secs / 60) % 60;
205 let ss = secs % 60;
206 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
207}
208
209pub(crate) fn parse_iso_to_secs(ts: &str) -> u64 {
211 if ts.len() < 19 {
212 return 0;
213 }
214 let year: u64 = ts[0..4].parse().unwrap_or(0);
215 let month: u64 = ts[5..7].parse().unwrap_or(1);
216 let day: u64 = ts[8..10].parse().unwrap_or(1);
217 let hour: u64 = ts[11..13].parse().unwrap_or(0);
218 let min: u64 = ts[14..16].parse().unwrap_or(0);
219 let sec: u64 = ts[17..19].parse().unwrap_or(0);
220
221 let total_days = days_since_epoch(year, month, day);
222 total_days * 86400 + hour * 3600 + min * 60 + sec
223}
224
225fn days_since_epoch(year: u64, month: u64, day: u64) -> u64 {
226 let y = year as i64 - 1;
227 let base = 1969i64;
228 let leaps = (y / 4 - y / 100 + y / 400) - (base / 4 - base / 100 + base / 400);
229 let days_from_years = (year as i64 - 1970) * 365 + leaps;
230
231 const MONTH_DAYS: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
232 let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
233 let mut days_from_months: u64 = 0;
234 let prior_months = (month.saturating_sub(1) as usize).min(12);
235 for (m, &md) in MONTH_DAYS.iter().enumerate().take(prior_months) {
236 days_from_months += md;
237 if m == 1 && leap {
238 days_from_months += 1;
239 }
240 }
241 (days_from_years as u64) + days_from_months + day.saturating_sub(1)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::graph::schema::init_graph_schema;
248 use crate::graph::store::upsert_node;
249 use crate::graph::types::GraphNode;
250 use rusqlite::Connection;
251
252 fn mem_db() -> Connection {
253 let conn = Connection::open_in_memory().unwrap();
254 init_graph_schema(&conn).unwrap();
255 conn
256 }
257
258 fn test_node(id: &str, importance: f64, tags: Vec<&str>) -> GraphNode {
259 GraphNode {
260 id: id.to_string(),
261 node_type: "concept".to_string(),
262 title: format!("Node {id}"),
263 body: String::new(),
264 tags: tags.into_iter().map(|s| s.to_string()).collect(),
265 projects: vec![],
266 agents: vec![],
267 created: "2026-01-01T00:00:00Z".to_string(),
268 updated: "2026-01-01T00:00:00Z".to_string(),
269 importance,
270 access_count: 0,
271 accessed_at: String::new(),
272 ..Default::default()
273 }
274 }
275
276 #[test]
277 fn mark_verified_sets_timestamp() {
278 let conn = mem_db();
279 upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
280 assert!(mark_verified(&conn, "n1", "2026-08-18T00:00:00Z").unwrap());
281 let node = crate::graph::store::read_node(&conn, "n1")
282 .unwrap()
283 .unwrap();
284 assert_eq!(node.last_verified, "2026-08-18T00:00:00Z");
285 assert!(!mark_verified(&conn, "missing", "2026-08-18T00:00:00Z").unwrap());
286 }
287
288 #[test]
289 fn count_expired_nodes_only_counts_set_and_past() {
290 let conn = mem_db();
291 let mut expired = test_node("expired", 0.7, vec![]);
292 expired.valid_until = "2026-01-01T00:00:00Z".to_string();
293 let mut future = test_node("future", 0.7, vec![]);
294 future.valid_until = "2030-01-01T00:00:00Z".to_string();
295 upsert_node(&conn, &expired).unwrap();
296 upsert_node(&conn, &future).unwrap();
297 upsert_node(&conn, &test_node("unset", 0.7, vec![])).unwrap();
298 assert_eq!(
299 count_expired_nodes(&conn, "2026-08-18T00:00:00Z").unwrap(),
300 1
301 );
302 }
303
304 #[test]
305 fn touch_node_increments_count() {
306 let conn = mem_db();
307 upsert_node(&conn, &test_node("n1", 0.7, vec![])).unwrap();
308 touch_node(&conn, "n1");
309 touch_node(&conn, "n1");
310 let node = crate::graph::store::read_node(&conn, "n1")
311 .unwrap()
312 .unwrap();
313 assert_eq!(node.access_count, 2);
314 assert!(!node.accessed_at.is_empty());
315 }
316
317 #[test]
318 fn decay_reduces_importance() {
319 let conn = mem_db();
320 upsert_node(&conn, &test_node("n1", 0.8, vec![])).unwrap();
322 let changed = decay_importance(&conn, 30, 0.9, 0.05).unwrap();
323 assert!(changed > 0);
324 let node = crate::graph::store::read_node(&conn, "n1")
325 .unwrap()
326 .unwrap();
327 assert!(node.importance < 0.8);
328 }
329
330 #[test]
331 fn decay_skips_pinned() {
332 let conn = mem_db();
333 upsert_node(&conn, &test_node("n1", 0.9, vec!["pinned"])).unwrap();
334 let changed = decay_importance(&conn, 30, 0.9, 0.05).unwrap();
335 assert_eq!(changed, 0);
336 }
337
338 #[test]
339 fn tag_stale_marks_old_nodes() {
340 let conn = mem_db();
341 upsert_node(&conn, &test_node("n1", 0.5, vec![])).unwrap();
342 let changed = tag_stale_nodes(&conn, 30).unwrap();
343 assert!(changed > 0);
344 let node = crate::graph::store::read_node(&conn, "n1")
345 .unwrap()
346 .unwrap();
347 assert!(node.tags.contains(&"stale".to_string()));
348 }
349
350 #[test]
351 fn compute_stats_returns_counts() {
352 let conn = mem_db();
353 let mut n1 = test_node("n1", 0.7, vec![]);
354 n1.node_type = "decision".to_string();
355 upsert_node(&conn, &n1).unwrap();
356 upsert_node(&conn, &test_node("n2", 0.5, vec![])).unwrap();
357
358 let stats = compute_stats(&conn).unwrap();
359 assert_eq!(stats.total_nodes, 2);
360 assert_eq!(stats.total_edges, 0);
361 assert!(stats.by_type.contains_key("decision"));
362 }
363
364 #[test]
365 fn parse_iso_roundtrip() {
366 let secs = parse_iso_to_secs("2026-01-15T12:30:45Z");
367 assert!(secs > 0);
368 }
369}