1use crate::errors::CoreError;
2use crate::models::{
3 IngestedSession, KnowledgeEdge, KnowledgeNode, KnowledgeProject,
4 EdgeType, GraphOperation, NodeScope, NodeStatus, NodeType,
5 Pattern, PatternStatus, PatternType, Projection, ProjectionStatus, SuggestedTarget,
6};
7use chrono::{DateTime, Utc};
8pub use rusqlite::Connection;
9use rusqlite::params;
10use rusqlite::OptionalExtension;
11use std::path::Path;
12
13const SCHEMA_VERSION: u32 = 5;
14
15pub fn open_db(path: &Path) -> Result<Connection, CoreError> {
17 let conn = Connection::open(path)?;
18
19 conn.pragma_update(None, "journal_mode", "WAL")?;
21
22 migrate(&conn)?;
24
25 Ok(conn)
26}
27
28pub fn init_db(conn: &Connection) -> Result<(), CoreError> {
30 migrate(conn)
31}
32
33fn migrate(conn: &Connection) -> Result<(), CoreError> {
34 let current_version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
35
36 if current_version < 1 {
37 conn.execute_batch(
38 "
39 CREATE TABLE IF NOT EXISTS patterns (
40 id TEXT PRIMARY KEY,
41 pattern_type TEXT NOT NULL,
42 description TEXT NOT NULL,
43 confidence REAL NOT NULL,
44 times_seen INTEGER NOT NULL DEFAULT 1,
45 first_seen TEXT NOT NULL,
46 last_seen TEXT NOT NULL,
47 last_projected TEXT,
48 status TEXT NOT NULL DEFAULT 'discovered',
49 source_sessions TEXT NOT NULL,
50 related_files TEXT NOT NULL,
51 suggested_content TEXT NOT NULL,
52 suggested_target TEXT NOT NULL,
53 project TEXT,
54 generation_failed INTEGER NOT NULL DEFAULT 0
55 );
56
57 CREATE TABLE IF NOT EXISTS projections (
58 id TEXT PRIMARY KEY,
59 pattern_id TEXT NOT NULL REFERENCES patterns(id),
60 target_type TEXT NOT NULL,
61 target_path TEXT NOT NULL,
62 content TEXT NOT NULL,
63 applied_at TEXT NOT NULL,
64 pr_url TEXT,
65 nudged INTEGER NOT NULL DEFAULT 0
66 );
67
68 CREATE TABLE IF NOT EXISTS analyzed_sessions (
69 session_id TEXT PRIMARY KEY,
70 project TEXT NOT NULL,
71 analyzed_at TEXT NOT NULL
72 );
73
74 CREATE TABLE IF NOT EXISTS ingested_sessions (
75 session_id TEXT PRIMARY KEY,
76 project TEXT NOT NULL,
77 session_path TEXT NOT NULL,
78 file_size INTEGER NOT NULL,
79 file_mtime TEXT NOT NULL,
80 ingested_at TEXT NOT NULL
81 );
82
83 CREATE INDEX IF NOT EXISTS idx_patterns_status ON patterns(status);
84 CREATE INDEX IF NOT EXISTS idx_patterns_type ON patterns(pattern_type);
85 CREATE INDEX IF NOT EXISTS idx_patterns_target ON patterns(suggested_target);
86 CREATE INDEX IF NOT EXISTS idx_patterns_project ON patterns(project);
87 CREATE INDEX IF NOT EXISTS idx_projections_pattern ON projections(pattern_id);
88 ",
89 )?;
90
91 conn.pragma_update(None, "user_version", 1)?;
92 }
93
94 if current_version < 2 {
95 conn.execute_batch(
96 "
97 CREATE TABLE IF NOT EXISTS metadata (
98 key TEXT PRIMARY KEY,
99 value TEXT NOT NULL
100 );
101 ",
102 )?;
103 conn.pragma_update(None, "user_version", 2)?;
104 }
105
106 if current_version < 3 {
107 conn.execute_batch(
108 "ALTER TABLE projections ADD COLUMN status TEXT NOT NULL DEFAULT 'applied';",
109 )?;
110 conn.pragma_update(None, "user_version", 3)?;
111 }
112
113 if current_version < 4 {
114 conn.execute_batch(
115 "
116 CREATE TABLE IF NOT EXISTS nodes (
117 id TEXT PRIMARY KEY,
118 type TEXT NOT NULL,
119 scope TEXT NOT NULL,
120 project_id TEXT,
121 content TEXT NOT NULL,
122 confidence REAL NOT NULL,
123 status TEXT NOT NULL,
124 created_at TEXT NOT NULL,
125 updated_at TEXT NOT NULL,
126 projected_at TEXT,
127 pr_url TEXT
128 );
129
130 CREATE INDEX IF NOT EXISTS idx_nodes_scope_project ON nodes(scope, project_id, status);
131 CREATE INDEX IF NOT EXISTS idx_nodes_type_status ON nodes(type, status);
132
133 CREATE TABLE IF NOT EXISTS edges (
134 source_id TEXT NOT NULL,
135 target_id TEXT NOT NULL,
136 type TEXT NOT NULL,
137 created_at TEXT NOT NULL,
138 PRIMARY KEY (source_id, target_id, type)
139 );
140
141 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, type);
142 CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, type);
143
144 CREATE TABLE IF NOT EXISTS projects (
145 id TEXT PRIMARY KEY,
146 path TEXT NOT NULL,
147 remote_url TEXT,
148 agent_type TEXT NOT NULL DEFAULT 'claude_code',
149 last_seen TEXT NOT NULL
150 );
151 ",
152 )?;
153
154 migrate_patterns_to_nodes(conn)?;
156
157 conn.pragma_update(None, "user_version", 4)?;
158 }
159
160 if current_version < 5 {
161 let has_projected_at: bool = conn
164 .prepare("SELECT projected_at FROM nodes LIMIT 0")
165 .is_ok();
166 if !has_projected_at {
167 conn.execute_batch(
168 "ALTER TABLE nodes ADD COLUMN projected_at TEXT;
169 ALTER TABLE nodes ADD COLUMN pr_url TEXT;"
170 )?;
171 }
172 conn.pragma_update(None, "user_version", 5)?;
173 }
174
175 if current_version < 6 {
176 conn.execute_batch(
181 "UPDATE patterns SET status = 'archived' WHERE status IN ('discovered', 'active');
182 DELETE FROM projections WHERE status = 'pending_review';
183 DELETE FROM projects WHERE path = '/';
184 DELETE FROM ingested_sessions WHERE project = '/';"
185 )?;
186 conn.pragma_update(None, "user_version", 6)?;
187 }
188
189 Ok(())
190}
191
192pub fn is_session_ingested(
194 conn: &Connection,
195 session_id: &str,
196 file_size: u64,
197 file_mtime: &str,
198) -> Result<bool, CoreError> {
199 let mut stmt = conn.prepare(
200 "SELECT file_size, file_mtime FROM ingested_sessions WHERE session_id = ?1",
201 )?;
202
203 let result = stmt.query_row(params![session_id], |row| {
204 let size: u64 = row.get(0)?;
205 let mtime: String = row.get(1)?;
206 Ok((size, mtime))
207 });
208
209 match result {
210 Ok((size, mtime)) => Ok(size == file_size && mtime == file_mtime),
211 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
212 Err(e) => Err(CoreError::Database(e.to_string())),
213 }
214}
215
216pub fn record_ingested_session(
218 conn: &Connection,
219 session: &IngestedSession,
220) -> Result<(), CoreError> {
221 conn.execute(
222 "INSERT OR REPLACE INTO ingested_sessions (session_id, project, session_path, file_size, file_mtime, ingested_at)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
224 params![
225 session.session_id,
226 session.project,
227 session.session_path,
228 session.file_size,
229 session.file_mtime,
230 session.ingested_at.to_rfc3339(),
231 ],
232 )?;
233 Ok(())
234}
235
236pub fn ingested_session_count(conn: &Connection) -> Result<u64, CoreError> {
238 let count: u64 =
239 conn.query_row("SELECT COUNT(*) FROM ingested_sessions", [], |row| {
240 row.get(0)
241 })?;
242 Ok(count)
243}
244
245pub fn ingested_session_count_for_project(
247 conn: &Connection,
248 project: &str,
249) -> Result<u64, CoreError> {
250 let count: u64 = conn.query_row(
251 "SELECT COUNT(*) FROM ingested_sessions WHERE project = ?1",
252 params![project],
253 |row| row.get(0),
254 )?;
255 Ok(count)
256}
257
258pub fn analyzed_session_count(conn: &Connection) -> Result<u64, CoreError> {
260 let count: u64 =
261 conn.query_row("SELECT COUNT(*) FROM analyzed_sessions", [], |row| {
262 row.get(0)
263 })?;
264 Ok(count)
265}
266
267pub fn pattern_count_by_status(conn: &Connection, status: &str) -> Result<u64, CoreError> {
269 let count: u64 = conn.query_row(
270 "SELECT COUNT(*) FROM patterns WHERE status = ?1",
271 params![status],
272 |row| row.get(0),
273 )?;
274 Ok(count)
275}
276
277pub fn last_ingested_at(conn: &Connection) -> Result<Option<String>, CoreError> {
279 let result = conn.query_row(
280 "SELECT MAX(ingested_at) FROM ingested_sessions",
281 [],
282 |row| row.get::<_, Option<String>>(0),
283 )?;
284 Ok(result)
285}
286
287pub fn last_analyzed_at(conn: &Connection) -> Result<Option<String>, CoreError> {
289 let result = conn.query_row(
290 "SELECT MAX(analyzed_at) FROM analyzed_sessions",
291 [],
292 |row| row.get::<_, Option<String>>(0),
293 )?;
294 Ok(result)
295}
296
297pub fn last_applied_at(conn: &Connection) -> Result<Option<String>, CoreError> {
299 let result = conn.query_row(
300 "SELECT MAX(applied_at) FROM projections",
301 [],
302 |row| row.get::<_, Option<String>>(0),
303 )?;
304 Ok(result)
305}
306
307pub fn has_unanalyzed_sessions(conn: &Connection) -> Result<bool, CoreError> {
309 let count: u64 = conn.query_row(
310 "SELECT COUNT(*) FROM ingested_sessions i
311 LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
312 WHERE a.session_id IS NULL",
313 [],
314 |row| row.get(0),
315 )?;
316 Ok(count > 0)
317}
318
319pub fn unanalyzed_session_count(conn: &Connection) -> Result<u64, CoreError> {
321 let count: u64 = conn.query_row(
322 "SELECT COUNT(*) FROM ingested_sessions i
323 LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
324 WHERE a.session_id IS NULL",
325 [],
326 |row| row.get(0),
327 )?;
328 Ok(count)
329}
330
331pub fn has_unprojected_patterns(conn: &Connection, confidence_threshold: f64) -> Result<bool, CoreError> {
336 let count: u64 = conn.query_row(
337 "SELECT COUNT(*) FROM patterns p
338 LEFT JOIN projections pr ON p.id = pr.pattern_id
339 WHERE pr.id IS NULL
340 AND p.status IN ('discovered', 'active')
341 AND p.generation_failed = 0
342 AND p.suggested_target != 'db_only'
343 AND p.confidence >= ?1",
344 [confidence_threshold],
345 |row| row.get(0),
346 )?;
347 Ok(count > 0)
348}
349
350pub fn get_last_nudge_at(conn: &Connection) -> Result<Option<DateTime<Utc>>, CoreError> {
352 let result: Option<String> = conn
353 .query_row(
354 "SELECT value FROM metadata WHERE key = 'last_nudge_at'",
355 [],
356 |row| row.get(0),
357 )
358 .optional()?;
359
360 match result {
361 Some(s) => match DateTime::parse_from_rfc3339(&s) {
362 Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
363 Err(_) => Ok(None),
364 },
365 None => Ok(None),
366 }
367}
368
369pub fn set_last_nudge_at(conn: &Connection, timestamp: &DateTime<Utc>) -> Result<(), CoreError> {
371 conn.execute(
372 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('last_nudge_at', ?1)",
373 params![timestamp.to_rfc3339()],
374 )?;
375 Ok(())
376}
377
378pub fn get_metadata(conn: &Connection, key: &str) -> Result<Option<String>, CoreError> {
380 let result: Option<String> = conn
381 .query_row(
382 "SELECT value FROM metadata WHERE key = ?1",
383 params![key],
384 |row| row.get(0),
385 )
386 .optional()?;
387 Ok(result)
388}
389
390pub fn set_metadata(conn: &Connection, key: &str, value: &str) -> Result<(), CoreError> {
392 conn.execute(
393 "INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)",
394 params![key, value],
395 )?;
396 Ok(())
397}
398
399pub fn verify_wal_mode(conn: &Connection) -> Result<bool, CoreError> {
401 let mode: String = conn.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
402 Ok(mode.to_lowercase() == "wal")
403}
404
405pub fn list_projects(conn: &Connection) -> Result<Vec<String>, CoreError> {
407 let mut stmt =
408 conn.prepare("SELECT DISTINCT project FROM ingested_sessions ORDER BY project")?;
409 let projects = stmt
410 .query_map([], |row| row.get(0))?
411 .filter_map(|r| r.ok())
412 .collect();
413 Ok(projects)
414}
415
416const PATTERN_COLUMNS: &str = "id, pattern_type, description, confidence, times_seen, first_seen, last_seen, last_projected, status, source_sessions, related_files, suggested_content, suggested_target, project, generation_failed";
419
420pub fn insert_pattern(conn: &Connection, pattern: &Pattern) -> Result<(), CoreError> {
422 let source_sessions =
423 serde_json::to_string(&pattern.source_sessions).unwrap_or_else(|_| "[]".to_string());
424 let related_files =
425 serde_json::to_string(&pattern.related_files).unwrap_or_else(|_| "[]".to_string());
426
427 conn.execute(
428 "INSERT INTO patterns (id, pattern_type, description, confidence, times_seen, first_seen, last_seen, last_projected, status, source_sessions, related_files, suggested_content, suggested_target, project, generation_failed)
429 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
430 params![
431 pattern.id,
432 pattern.pattern_type.to_string(),
433 pattern.description,
434 pattern.confidence,
435 pattern.times_seen,
436 pattern.first_seen.to_rfc3339(),
437 pattern.last_seen.to_rfc3339(),
438 pattern.last_projected.map(|t| t.to_rfc3339()),
439 pattern.status.to_string(),
440 source_sessions,
441 related_files,
442 pattern.suggested_content,
443 pattern.suggested_target.to_string(),
444 pattern.project,
445 pattern.generation_failed as i32,
446 ],
447 )?;
448 Ok(())
449}
450
451pub fn update_pattern_merge(
453 conn: &Connection,
454 id: &str,
455 new_sessions: &[String],
456 new_confidence: f64,
457 new_last_seen: DateTime<Utc>,
458 additional_times_seen: i64,
459) -> Result<(), CoreError> {
460 let existing_sessions: String = conn.query_row(
462 "SELECT source_sessions FROM patterns WHERE id = ?1",
463 params![id],
464 |row| row.get(0),
465 )?;
466
467 let mut sessions: Vec<String> =
468 serde_json::from_str(&existing_sessions).unwrap_or_default();
469 for s in new_sessions {
470 if !sessions.contains(s) {
471 sessions.push(s.clone());
472 }
473 }
474 let merged_sessions = serde_json::to_string(&sessions).unwrap_or_else(|_| "[]".to_string());
475
476 conn.execute(
477 "UPDATE patterns SET
478 confidence = MAX(confidence, ?2),
479 times_seen = times_seen + ?3,
480 last_seen = ?4,
481 source_sessions = ?5
482 WHERE id = ?1",
483 params![
484 id,
485 new_confidence,
486 additional_times_seen,
487 new_last_seen.to_rfc3339(),
488 merged_sessions,
489 ],
490 )?;
491 Ok(())
492}
493
494pub fn get_patterns(
496 conn: &Connection,
497 statuses: &[&str],
498 project: Option<&str>,
499) -> Result<Vec<Pattern>, CoreError> {
500 if statuses.is_empty() {
501 return Ok(Vec::new());
502 }
503
504 let placeholders: Vec<String> = statuses.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
505 let status_clause = placeholders.join(", ");
506
507 let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match project {
508 Some(proj) => {
509 let q = format!(
510 "SELECT {PATTERN_COLUMNS}
511 FROM patterns WHERE status IN ({}) AND (project = ?{} OR project IS NULL)
512 ORDER BY confidence DESC",
513 status_clause,
514 statuses.len() + 1
515 );
516 let mut p: Vec<Box<dyn rusqlite::types::ToSql>> = statuses.iter().map(|s| Box::new(s.to_string()) as Box<dyn rusqlite::types::ToSql>).collect();
517 p.push(Box::new(proj.to_string()));
518 (q, p)
519 }
520 None => {
521 let q = format!(
522 "SELECT {PATTERN_COLUMNS}
523 FROM patterns WHERE status IN ({})
524 ORDER BY confidence DESC",
525 status_clause
526 );
527 let p: Vec<Box<dyn rusqlite::types::ToSql>> = statuses.iter().map(|s| Box::new(s.to_string()) as Box<dyn rusqlite::types::ToSql>).collect();
528 (q, p)
529 }
530 };
531
532 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
533 let mut stmt = conn.prepare(&query)?;
534 let patterns = stmt
535 .query_map(params_refs.as_slice(), |row| {
536 Ok(read_pattern_row(row))
537 })?
538 .filter_map(|r| r.ok())
539 .collect();
540
541 Ok(patterns)
542}
543
544pub fn get_all_patterns(conn: &Connection, project: Option<&str>) -> Result<Vec<Pattern>, CoreError> {
546 let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match project {
547 Some(proj) => {
548 let q = format!(
549 "SELECT {PATTERN_COLUMNS}
550 FROM patterns WHERE project = ?1 OR project IS NULL
551 ORDER BY confidence DESC"
552 );
553 (q, vec![Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>])
554 }
555 None => {
556 let q = format!(
557 "SELECT {PATTERN_COLUMNS}
558 FROM patterns ORDER BY confidence DESC"
559 );
560 (q, vec![])
561 }
562 };
563
564 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
565 let mut stmt = conn.prepare(&query)?;
566 let patterns = stmt
567 .query_map(params_refs.as_slice(), |row| Ok(read_pattern_row(row)))?
568 .filter_map(|r| r.ok())
569 .collect();
570
571 Ok(patterns)
572}
573
574fn read_pattern_row(row: &rusqlite::Row<'_>) -> Pattern {
575 let source_sessions_str: String = row.get(9).unwrap_or_default();
576 let related_files_str: String = row.get(10).unwrap_or_default();
577 let first_seen_str: String = row.get(5).unwrap_or_default();
578 let last_seen_str: String = row.get(6).unwrap_or_default();
579 let last_projected_str: Option<String> = row.get(7).unwrap_or(None);
580 let gen_failed: i32 = row.get(14).unwrap_or(0);
581
582 Pattern {
583 id: row.get(0).unwrap_or_default(),
584 pattern_type: PatternType::from_str(&row.get::<_, String>(1).unwrap_or_default()),
585 description: row.get(2).unwrap_or_default(),
586 confidence: row.get(3).unwrap_or(0.0),
587 times_seen: row.get(4).unwrap_or(1),
588 first_seen: DateTime::parse_from_rfc3339(&first_seen_str)
589 .map(|d| d.with_timezone(&Utc))
590 .unwrap_or_else(|_| Utc::now()),
591 last_seen: DateTime::parse_from_rfc3339(&last_seen_str)
592 .map(|d| d.with_timezone(&Utc))
593 .unwrap_or_else(|_| Utc::now()),
594 last_projected: last_projected_str
595 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
596 .map(|d| d.with_timezone(&Utc)),
597 status: PatternStatus::from_str(&row.get::<_, String>(8).unwrap_or_default()),
598 source_sessions: serde_json::from_str(&source_sessions_str).unwrap_or_default(),
599 related_files: serde_json::from_str(&related_files_str).unwrap_or_default(),
600 suggested_content: row.get(11).unwrap_or_default(),
601 suggested_target: SuggestedTarget::from_str(&row.get::<_, String>(12).unwrap_or_default()),
602 project: row.get(13).unwrap_or(None),
603 generation_failed: gen_failed != 0,
604 }
605}
606
607pub fn record_analyzed_session(
611 conn: &Connection,
612 session_id: &str,
613 project: &str,
614) -> Result<(), CoreError> {
615 conn.execute(
616 "INSERT OR REPLACE INTO analyzed_sessions (session_id, project, analyzed_at)
617 VALUES (?1, ?2, ?3)",
618 params![session_id, project, Utc::now().to_rfc3339()],
619 )?;
620 Ok(())
621}
622
623pub fn is_session_analyzed(conn: &Connection, session_id: &str) -> Result<bool, CoreError> {
625 let count: u64 = conn.query_row(
626 "SELECT COUNT(*) FROM analyzed_sessions WHERE session_id = ?1",
627 params![session_id],
628 |row| row.get(0),
629 )?;
630 Ok(count > 0)
631}
632
633pub fn get_sessions_for_analysis(
637 conn: &Connection,
638 project: Option<&str>,
639 since: &DateTime<Utc>,
640 rolling_window: bool,
641) -> Result<Vec<IngestedSession>, CoreError> {
642 let since_str = since.to_rfc3339();
643
644 let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (project, rolling_window) {
645 (Some(proj), true) => {
646 let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
647 FROM ingested_sessions i
648 WHERE i.project = ?1 AND i.ingested_at >= ?2
649 ORDER BY i.ingested_at".to_string();
650 (q, vec![
651 Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>,
652 Box::new(since_str) as Box<dyn rusqlite::types::ToSql>,
653 ])
654 }
655 (Some(proj), false) => {
656 let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
657 FROM ingested_sessions i
658 LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
659 WHERE a.session_id IS NULL AND i.project = ?1 AND i.ingested_at >= ?2
660 ORDER BY i.ingested_at".to_string();
661 (q, vec![
662 Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>,
663 Box::new(since_str) as Box<dyn rusqlite::types::ToSql>,
664 ])
665 }
666 (None, true) => {
667 let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
668 FROM ingested_sessions i
669 WHERE i.ingested_at >= ?1
670 ORDER BY i.ingested_at".to_string();
671 (q, vec![Box::new(since_str) as Box<dyn rusqlite::types::ToSql>])
672 }
673 (None, false) => {
674 let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
675 FROM ingested_sessions i
676 LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
677 WHERE a.session_id IS NULL AND i.ingested_at >= ?1
678 ORDER BY i.ingested_at".to_string();
679 (q, vec![Box::new(since_str) as Box<dyn rusqlite::types::ToSql>])
680 }
681 };
682
683 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
684 let mut stmt = conn.prepare(&query)?;
685 let sessions = stmt
686 .query_map(params_refs.as_slice(), |row| {
687 let ingested_at_str: String = row.get(5)?;
688 let ingested_at = DateTime::parse_from_rfc3339(&ingested_at_str)
689 .map(|d| d.with_timezone(&Utc))
690 .unwrap_or_else(|_| Utc::now());
691 Ok(IngestedSession {
692 session_id: row.get(0)?,
693 project: row.get(1)?,
694 session_path: row.get(2)?,
695 file_size: row.get(3)?,
696 file_mtime: row.get(4)?,
697 ingested_at,
698 })
699 })?
700 .filter_map(|r| r.ok())
701 .collect();
702
703 Ok(sessions)
704}
705
706pub fn insert_projection(conn: &Connection, proj: &Projection) -> Result<(), CoreError> {
710 conn.execute(
711 "INSERT INTO projections (id, pattern_id, target_type, target_path, content, applied_at, pr_url, status)
712 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
713 params![
714 proj.id,
715 proj.pattern_id,
716 proj.target_type,
717 proj.target_path,
718 proj.content,
719 proj.applied_at.to_rfc3339(),
720 proj.pr_url,
721 proj.status.to_string(),
722 ],
723 )?;
724 Ok(())
725}
726
727pub fn has_projection_for_pattern(conn: &Connection, pattern_id: &str) -> Result<bool, CoreError> {
729 let count: u64 = conn.query_row(
730 "SELECT COUNT(*) FROM projections WHERE pattern_id = ?1",
731 params![pattern_id],
732 |row| row.get(0),
733 )?;
734 Ok(count > 0)
735}
736
737pub fn get_projected_pattern_ids(
739 conn: &Connection,
740) -> Result<std::collections::HashSet<String>, CoreError> {
741 let mut stmt = conn.prepare("SELECT DISTINCT pattern_id FROM projections")?;
742 let ids = stmt
743 .query_map([], |row| row.get(0))?
744 .filter_map(|r| r.ok())
745 .collect();
746 Ok(ids)
747}
748
749pub fn update_pattern_status(
751 conn: &Connection,
752 id: &str,
753 status: &PatternStatus,
754) -> Result<(), CoreError> {
755 conn.execute(
756 "UPDATE patterns SET status = ?2 WHERE id = ?1",
757 params![id, status.to_string()],
758 )?;
759 Ok(())
760}
761
762pub fn set_generation_failed(
764 conn: &Connection,
765 id: &str,
766 failed: bool,
767) -> Result<(), CoreError> {
768 conn.execute(
769 "UPDATE patterns SET generation_failed = ?2 WHERE id = ?1",
770 params![id, failed as i32],
771 )?;
772 Ok(())
773}
774
775pub fn get_projections_for_active_patterns(
777 conn: &Connection,
778) -> Result<Vec<Projection>, CoreError> {
779 let mut stmt = conn.prepare(
780 "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
781 FROM projections p
782 INNER JOIN patterns pat ON p.pattern_id = pat.id
783 WHERE pat.status = 'active'",
784 )?;
785
786 let projections = stmt
787 .query_map([], |row| {
788 let applied_at_str: String = row.get(5)?;
789 let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
790 .map(|d| d.with_timezone(&Utc))
791 .unwrap_or_else(|_| Utc::now());
792 let status_str: String = row.get(7)?;
793 let status = ProjectionStatus::from_str(&status_str)
794 .unwrap_or(ProjectionStatus::Applied);
795 Ok(Projection {
796 id: row.get(0)?,
797 pattern_id: row.get(1)?,
798 target_type: row.get(2)?,
799 target_path: row.get(3)?,
800 content: row.get(4)?,
801 applied_at,
802 pr_url: row.get(6)?,
803 status,
804 })
805 })?
806 .filter_map(|r| r.ok())
807 .collect();
808
809 Ok(projections)
810}
811
812pub fn update_pattern_last_projected(conn: &Connection, id: &str) -> Result<(), CoreError> {
814 conn.execute(
815 "UPDATE patterns SET last_projected = ?2 WHERE id = ?1",
816 params![id, Utc::now().to_rfc3339()],
817 )?;
818 Ok(())
819}
820
821pub fn get_pending_review_projections(conn: &Connection) -> Result<Vec<Projection>, CoreError> {
823 let mut stmt = conn.prepare(
824 "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
825 FROM projections p
826 WHERE p.status = 'pending_review'
827 ORDER BY p.applied_at ASC",
828 )?;
829
830 let projections = stmt
831 .query_map([], |row| {
832 let applied_at_str: String = row.get(5)?;
833 let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
834 .map(|d| d.with_timezone(&Utc))
835 .unwrap_or_else(|_| Utc::now());
836 let status_str: String = row.get(7)?;
837 let status = ProjectionStatus::from_str(&status_str)
838 .unwrap_or(ProjectionStatus::PendingReview);
839 Ok(Projection {
840 id: row.get(0)?,
841 pattern_id: row.get(1)?,
842 target_type: row.get(2)?,
843 target_path: row.get(3)?,
844 content: row.get(4)?,
845 applied_at,
846 pr_url: row.get(6)?,
847 status,
848 })
849 })?
850 .filter_map(|r| r.ok())
851 .collect();
852
853 Ok(projections)
854}
855
856pub fn update_projection_status(
858 conn: &Connection,
859 projection_id: &str,
860 status: &ProjectionStatus,
861) -> Result<(), CoreError> {
862 conn.execute(
863 "UPDATE projections SET status = ?2 WHERE id = ?1",
864 params![projection_id, status.to_string()],
865 )?;
866 Ok(())
867}
868
869pub fn delete_projection(conn: &Connection, projection_id: &str) -> Result<(), CoreError> {
871 conn.execute("DELETE FROM projections WHERE id = ?1", params![projection_id])?;
872 Ok(())
873}
874
875pub fn get_applied_projections_with_pr(conn: &Connection) -> Result<Vec<Projection>, CoreError> {
877 let mut stmt = conn.prepare(
878 "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
879 FROM projections p
880 WHERE p.status = 'applied' AND p.pr_url IS NOT NULL",
881 )?;
882
883 let projections = stmt
884 .query_map([], |row| {
885 let applied_at_str: String = row.get(5)?;
886 let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
887 .map(|d| d.with_timezone(&Utc))
888 .unwrap_or_else(|_| Utc::now());
889 let status_str: String = row.get(7)?;
890 let status = ProjectionStatus::from_str(&status_str)
891 .unwrap_or(ProjectionStatus::Applied);
892 Ok(Projection {
893 id: row.get(0)?,
894 pattern_id: row.get(1)?,
895 target_type: row.get(2)?,
896 target_path: row.get(3)?,
897 content: row.get(4)?,
898 applied_at,
899 pr_url: row.get(6)?,
900 status,
901 })
902 })?
903 .filter_map(|r| r.ok())
904 .collect();
905
906 Ok(projections)
907}
908
909pub fn get_projected_pattern_ids_by_status(
911 conn: &Connection,
912 statuses: &[ProjectionStatus],
913) -> Result<std::collections::HashSet<String>, CoreError> {
914 if statuses.is_empty() {
915 return Ok(std::collections::HashSet::new());
916 }
917 let placeholders: Vec<String> = statuses.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
918 let sql = format!(
919 "SELECT DISTINCT pattern_id FROM projections WHERE status IN ({})",
920 placeholders.join(", ")
921 );
922 let mut stmt = conn.prepare(&sql)?;
923 let params: Vec<String> = statuses.iter().map(|s| s.to_string()).collect();
924 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
925 let ids = stmt
926 .query_map(param_refs.as_slice(), |row| row.get(0))?
927 .filter_map(|r| r.ok())
928 .collect();
929 Ok(ids)
930}
931
932pub fn update_projection_pr_url(
934 conn: &Connection,
935 projection_id: &str,
936 pr_url: &str,
937) -> Result<(), CoreError> {
938 conn.execute(
939 "UPDATE projections SET pr_url = ?2 WHERE id = ?1",
940 params![projection_id, pr_url],
941 )?;
942 Ok(())
943}
944
945pub fn migrate_patterns_to_nodes(conn: &Connection) -> Result<usize, CoreError> {
950 let mut stmt = conn.prepare(
951 "SELECT id, pattern_type, description, confidence, status, suggested_content, suggested_target, project, first_seen, last_seen
952 FROM patterns",
953 )?;
954 let patterns: Vec<(String, String, String, f64, String, String, String, Option<String>, String, String)> = stmt.query_map([], |row| {
955 Ok((
956 row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?,
957 row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?,
958 row.get(8)?, row.get(9)?,
959 ))
960 })?.filter_map(|r| r.ok()).collect();
961
962 let mut count = 0;
963 for (id, pattern_type, description, confidence, _status, suggested_content, suggested_target, project, first_seen, last_seen) in &patterns {
964 let node_id = format!("migrated-{id}");
965
966 if get_node(conn, &node_id)?.is_some() {
968 continue;
969 }
970
971 let content_lower = suggested_content.to_lowercase();
973 let has_directive_keyword = content_lower.contains("always") || content_lower.contains("never");
974 let node_type = if *confidence >= 0.85 && has_directive_keyword {
975 NodeType::Directive
976 } else {
977 match (pattern_type.as_str(), suggested_target.as_str()) {
978 ("repetitive_instruction", "claude_md") => NodeType::Rule,
979 ("repetitive_instruction", "skill") => NodeType::Directive,
980 ("recurring_mistake", _) => NodeType::Pattern,
981 ("workflow_pattern", "skill") => NodeType::Skill,
982 ("workflow_pattern", "claude_md") => NodeType::Rule,
983 ("stale_context", _) => NodeType::Memory,
984 ("redundant_context", _) => NodeType::Memory,
985 _ => NodeType::Pattern,
986 }
987 };
988
989 let created_at = DateTime::parse_from_rfc3339(first_seen)
990 .unwrap_or_default()
991 .with_timezone(&Utc);
992 let updated_at = DateTime::parse_from_rfc3339(last_seen)
993 .unwrap_or_default()
994 .with_timezone(&Utc);
995
996 let node = KnowledgeNode {
997 id: node_id,
998 node_type,
999 scope: NodeScope::Project,
1000 project_id: project.clone(),
1001 content: description.clone(),
1002 confidence: *confidence,
1003 status: NodeStatus::Active,
1004 created_at,
1005 updated_at,
1006 projected_at: None,
1007 pr_url: None,
1008 };
1009 insert_node(conn, &node)?;
1010 count += 1;
1011 }
1012 Ok(count)
1013}
1014
1015pub fn insert_node(conn: &Connection, node: &KnowledgeNode) -> Result<(), CoreError> {
1018 conn.execute(
1019 "INSERT INTO nodes (id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url)
1020 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1021 params![
1022 node.id,
1023 node.node_type.to_string(),
1024 node.scope.to_string(),
1025 node.project_id,
1026 node.content,
1027 node.confidence,
1028 node.status.to_string(),
1029 node.created_at.to_rfc3339(),
1030 node.updated_at.to_rfc3339(),
1031 node.projected_at,
1032 node.pr_url,
1033 ],
1034 )?;
1035 Ok(())
1036}
1037
1038pub fn get_node(conn: &Connection, id: &str) -> Result<Option<KnowledgeNode>, CoreError> {
1039 let mut stmt = conn.prepare(
1040 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1041 FROM nodes WHERE id = ?1",
1042 )?;
1043 let result = stmt.query_row(params![id], |row| {
1044 Ok(KnowledgeNode {
1045 id: row.get(0)?,
1046 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1047 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1048 project_id: row.get(3)?,
1049 content: row.get(4)?,
1050 confidence: row.get(5)?,
1051 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1052 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1053 .unwrap_or_default()
1054 .with_timezone(&Utc),
1055 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1056 .unwrap_or_default()
1057 .with_timezone(&Utc),
1058 projected_at: row.get(9)?,
1059 pr_url: row.get(10)?,
1060 })
1061 }).optional()?;
1062 Ok(result)
1063}
1064
1065pub fn get_nodes_by_scope(
1066 conn: &Connection,
1067 scope: &NodeScope,
1068 project_id: Option<&str>,
1069 statuses: &[NodeStatus],
1070) -> Result<Vec<KnowledgeNode>, CoreError> {
1071 if statuses.is_empty() {
1072 return Ok(Vec::new());
1073 }
1074 let status_placeholders: Vec<String> = statuses.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect();
1075 let sql = format!(
1076 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1077 FROM nodes WHERE scope = ?1 AND (?2 IS NULL OR project_id = ?2) AND status IN ({})
1078 ORDER BY confidence DESC",
1079 status_placeholders.join(", ")
1080 );
1081 let mut stmt = conn.prepare(&sql)?;
1082 let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1083 Box::new(scope.to_string()),
1084 Box::new(project_id.map(|s| s.to_string())),
1085 ];
1086 for s in statuses {
1087 params_vec.push(Box::new(s.to_string()));
1088 }
1089 let rows = stmt.query_map(rusqlite::params_from_iter(params_vec.iter().map(|p| p.as_ref())), |row| {
1090 Ok(KnowledgeNode {
1091 id: row.get(0)?,
1092 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1093 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1094 project_id: row.get(3)?,
1095 content: row.get(4)?,
1096 confidence: row.get(5)?,
1097 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1098 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1099 .unwrap_or_default()
1100 .with_timezone(&Utc),
1101 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1102 .unwrap_or_default()
1103 .with_timezone(&Utc),
1104 projected_at: row.get(9)?,
1105 pr_url: row.get(10)?,
1106 })
1107 })?;
1108 let mut nodes = Vec::new();
1109 for row in rows {
1110 nodes.push(row?);
1111 }
1112 Ok(nodes)
1113}
1114
1115pub fn get_nodes_by_status(
1117 conn: &Connection,
1118 status: &NodeStatus,
1119) -> Result<Vec<KnowledgeNode>, CoreError> {
1120 let mut stmt = conn.prepare(
1121 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1122 FROM nodes WHERE status = ?1
1123 ORDER BY confidence DESC",
1124 )?;
1125 let rows = stmt.query_map(params![status.to_string()], |row| {
1126 Ok(KnowledgeNode {
1127 id: row.get(0)?,
1128 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1129 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1130 project_id: row.get(3)?,
1131 content: row.get(4)?,
1132 confidence: row.get(5)?,
1133 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1134 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1135 .unwrap_or_default()
1136 .with_timezone(&Utc),
1137 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1138 .unwrap_or_default()
1139 .with_timezone(&Utc),
1140 projected_at: row.get(9)?,
1141 pr_url: row.get(10)?,
1142 })
1143 })?;
1144 let mut nodes = Vec::new();
1145 for row in rows {
1146 nodes.push(row?);
1147 }
1148 Ok(nodes)
1149}
1150
1151pub fn update_node_confidence(conn: &Connection, id: &str, confidence: f64) -> Result<(), CoreError> {
1152 conn.execute(
1153 "UPDATE nodes SET confidence = ?1, updated_at = ?2 WHERE id = ?3",
1154 params![confidence, Utc::now().to_rfc3339(), id],
1155 )?;
1156 Ok(())
1157}
1158
1159pub fn update_node_status(conn: &Connection, id: &str, status: &NodeStatus) -> Result<(), CoreError> {
1160 conn.execute(
1161 "UPDATE nodes SET status = ?1, updated_at = ?2 WHERE id = ?3",
1162 params![status.to_string(), Utc::now().to_rfc3339(), id],
1163 )?;
1164 Ok(())
1165}
1166
1167pub fn update_node_content(conn: &Connection, id: &str, content: &str) -> Result<(), CoreError> {
1168 conn.execute(
1169 "UPDATE nodes SET content = ?1, updated_at = ?2 WHERE id = ?3",
1170 params![content, Utc::now().to_rfc3339(), id],
1171 )?;
1172 Ok(())
1173}
1174
1175pub fn insert_edge(conn: &Connection, edge: &KnowledgeEdge) -> Result<(), CoreError> {
1178 conn.execute(
1179 "INSERT OR IGNORE INTO edges (source_id, target_id, type, created_at)
1180 VALUES (?1, ?2, ?3, ?4)",
1181 params![
1182 edge.source_id,
1183 edge.target_id,
1184 edge.edge_type.to_string(),
1185 edge.created_at.to_rfc3339(),
1186 ],
1187 )?;
1188 Ok(())
1189}
1190
1191pub fn get_edges_from(conn: &Connection, source_id: &str) -> Result<Vec<KnowledgeEdge>, CoreError> {
1192 let mut stmt = conn.prepare(
1193 "SELECT source_id, target_id, type, created_at FROM edges WHERE source_id = ?1",
1194 )?;
1195 let rows = stmt.query_map(params![source_id], |row| {
1196 Ok(KnowledgeEdge {
1197 source_id: row.get(0)?,
1198 target_id: row.get(1)?,
1199 edge_type: EdgeType::from_str(&row.get::<_, String>(2)?).unwrap_or(EdgeType::Supports),
1200 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
1201 .unwrap_or_default()
1202 .with_timezone(&Utc),
1203 })
1204 })?;
1205 let mut edges = Vec::new();
1206 for row in rows {
1207 edges.push(row?);
1208 }
1209 Ok(edges)
1210}
1211
1212pub fn get_edges_to(conn: &Connection, target_id: &str) -> Result<Vec<KnowledgeEdge>, CoreError> {
1213 let mut stmt = conn.prepare(
1214 "SELECT source_id, target_id, type, created_at FROM edges WHERE target_id = ?1",
1215 )?;
1216 let rows = stmt.query_map(params![target_id], |row| {
1217 Ok(KnowledgeEdge {
1218 source_id: row.get(0)?,
1219 target_id: row.get(1)?,
1220 edge_type: EdgeType::from_str(&row.get::<_, String>(2)?).unwrap_or(EdgeType::Supports),
1221 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
1222 .unwrap_or_default()
1223 .with_timezone(&Utc),
1224 })
1225 })?;
1226 let mut edges = Vec::new();
1227 for row in rows {
1228 edges.push(row?);
1229 }
1230 Ok(edges)
1231}
1232
1233pub fn delete_edge(conn: &Connection, source_id: &str, target_id: &str, edge_type: &EdgeType) -> Result<(), CoreError> {
1234 conn.execute(
1235 "DELETE FROM edges WHERE source_id = ?1 AND target_id = ?2 AND type = ?3",
1236 params![source_id, target_id, edge_type.to_string()],
1237 )?;
1238 Ok(())
1239}
1240
1241pub fn supersede_node(conn: &Connection, new_id: &str, old_id: &str) -> Result<(), CoreError> {
1243 update_node_status(conn, old_id, &NodeStatus::Archived)?;
1244 let edge = KnowledgeEdge {
1245 source_id: new_id.to_string(),
1246 target_id: old_id.to_string(),
1247 edge_type: EdgeType::Supersedes,
1248 created_at: Utc::now(),
1249 };
1250 insert_edge(conn, &edge)?;
1251 Ok(())
1252}
1253
1254#[derive(Debug, Clone, Default)]
1256pub struct ApplyGraphResult {
1257 pub nodes_created: usize,
1258 pub nodes_updated: usize,
1259 pub edges_created: usize,
1260 pub nodes_merged: usize,
1261}
1262
1263pub fn apply_graph_operations(conn: &Connection, ops: &[GraphOperation]) -> Result<ApplyGraphResult, CoreError> {
1265 let mut result = ApplyGraphResult::default();
1266
1267 for op in ops {
1268 match op {
1269 GraphOperation::CreateNode { node_type, scope, project_id, content, confidence } => {
1270 let node = KnowledgeNode {
1271 id: uuid::Uuid::new_v4().to_string(),
1272 node_type: node_type.clone(),
1273 scope: scope.clone(),
1274 project_id: project_id.clone(),
1275 content: content.clone(),
1276 confidence: *confidence,
1277 status: NodeStatus::Active,
1278 created_at: Utc::now(),
1279 updated_at: Utc::now(),
1280 projected_at: None,
1281 pr_url: None,
1282 };
1283 insert_node(conn, &node)?;
1284 result.nodes_created += 1;
1285 }
1286 GraphOperation::UpdateNode { id, confidence, content } => {
1287 if let Some(conf) = confidence {
1288 update_node_confidence(conn, id, *conf)?;
1289 }
1290 if let Some(cont) = content {
1291 update_node_content(conn, id, cont)?;
1292 }
1293 result.nodes_updated += 1;
1294 }
1295 GraphOperation::CreateEdge { source_id, target_id, edge_type } => {
1296 let edge = KnowledgeEdge {
1297 source_id: source_id.clone(),
1298 target_id: target_id.clone(),
1299 edge_type: edge_type.clone(),
1300 created_at: Utc::now(),
1301 };
1302 insert_edge(conn, &edge)?;
1303 result.edges_created += 1;
1304 }
1305 GraphOperation::MergeNodes { keep_id, remove_id } => {
1306 supersede_node(conn, keep_id, remove_id)?;
1307 result.nodes_merged += 1;
1308 }
1309 }
1310 }
1311
1312 Ok(result)
1313}
1314
1315pub fn generate_project_slug(repo_path: &str) -> String {
1319 let name = std::path::Path::new(repo_path)
1320 .file_name()
1321 .and_then(|n| n.to_str())
1322 .unwrap_or("unnamed-project");
1323
1324 let slug: String = name
1325 .to_lowercase()
1326 .chars()
1327 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1328 .collect();
1329 let slug = slug.trim_matches('-').to_string();
1330 if slug.is_empty() {
1331 "unnamed-project".to_string()
1332 } else {
1333 let mut result = String::new();
1335 let mut prev_hyphen = false;
1336 for c in slug.chars() {
1337 if c == '-' {
1338 if !prev_hyphen {
1339 result.push(c);
1340 }
1341 prev_hyphen = true;
1342 } else {
1343 result.push(c);
1344 prev_hyphen = false;
1345 }
1346 }
1347 result
1348 }
1349}
1350
1351pub fn generate_unique_project_slug(conn: &Connection, repo_path: &str) -> Result<String, CoreError> {
1353 let base = generate_project_slug(repo_path);
1354 if get_project(conn, &base)?.is_none() {
1355 return Ok(base);
1356 }
1357 for i in 2..100 {
1358 let candidate = format!("{base}-{i}");
1359 if get_project(conn, &candidate)?.is_none() {
1360 return Ok(candidate);
1361 }
1362 }
1363 Ok(format!("{base}-{}", &uuid::Uuid::new_v4().to_string()[..8]))
1364}
1365
1366pub fn upsert_project(conn: &Connection, project: &KnowledgeProject) -> Result<(), CoreError> {
1367 conn.execute(
1368 "INSERT INTO projects (id, path, remote_url, agent_type, last_seen)
1369 VALUES (?1, ?2, ?3, ?4, ?5)
1370 ON CONFLICT(id) DO UPDATE SET
1371 path = excluded.path,
1372 remote_url = COALESCE(excluded.remote_url, projects.remote_url),
1373 last_seen = excluded.last_seen",
1374 params![
1375 project.id,
1376 project.path,
1377 project.remote_url,
1378 project.agent_type,
1379 project.last_seen.to_rfc3339(),
1380 ],
1381 )?;
1382 Ok(())
1383}
1384
1385pub fn get_project(conn: &Connection, id: &str) -> Result<Option<KnowledgeProject>, CoreError> {
1386 let mut stmt = conn.prepare(
1387 "SELECT id, path, remote_url, agent_type, last_seen FROM projects WHERE id = ?1",
1388 )?;
1389 let result = stmt.query_row(params![id], |row| {
1390 Ok(KnowledgeProject {
1391 id: row.get(0)?,
1392 path: row.get(1)?,
1393 remote_url: row.get(2)?,
1394 agent_type: row.get(3)?,
1395 last_seen: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
1396 .unwrap_or_default()
1397 .with_timezone(&Utc),
1398 })
1399 }).optional()?;
1400 Ok(result)
1401}
1402
1403pub fn get_project_by_path(conn: &Connection, path: &str) -> Result<Option<KnowledgeProject>, CoreError> {
1405 let mut stmt = conn.prepare(
1406 "SELECT id, path, remote_url, agent_type, last_seen FROM projects WHERE path = ?1",
1407 )?;
1408 let result = stmt.query_row(params![path], |row| {
1409 Ok(KnowledgeProject {
1410 id: row.get(0)?,
1411 path: row.get(1)?,
1412 remote_url: row.get(2)?,
1413 agent_type: row.get(3)?,
1414 last_seen: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
1415 .unwrap_or_default()
1416 .with_timezone(&Utc),
1417 })
1418 }).optional()?;
1419 Ok(result)
1420}
1421
1422pub fn ensure_project_registered(conn: &Connection, path: &str) -> Result<String, CoreError> {
1425 if let Some(existing) = get_project_by_path(conn, path)? {
1427 let mut updated = existing.clone();
1429 updated.last_seen = Utc::now();
1430 updated.remote_url = crate::git::remote_url().or(existing.remote_url);
1431 upsert_project(conn, &updated)?;
1432 return Ok(existing.id);
1433 }
1434
1435 let slug = generate_unique_project_slug(conn, path)?;
1437 let project = KnowledgeProject {
1438 id: slug.clone(),
1439 path: path.to_string(),
1440 remote_url: crate::git::remote_url(),
1441 agent_type: "claude_code".to_string(),
1442 last_seen: Utc::now(),
1443 };
1444 upsert_project(conn, &project)?;
1445 Ok(slug)
1446}
1447
1448pub fn get_project_by_remote_url(conn: &Connection, remote_url: &str) -> Result<Option<KnowledgeProject>, CoreError> {
1449 let mut stmt = conn.prepare(
1450 "SELECT id, path, remote_url, agent_type, last_seen FROM projects WHERE remote_url = ?1",
1451 )?;
1452 let result = stmt.query_row(params![remote_url], |row| {
1453 Ok(KnowledgeProject {
1454 id: row.get(0)?,
1455 path: row.get(1)?,
1456 remote_url: row.get(2)?,
1457 agent_type: row.get(3)?,
1458 last_seen: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
1459 .unwrap_or_default()
1460 .with_timezone(&Utc),
1461 })
1462 }).optional()?;
1463 Ok(result)
1464}
1465
1466pub fn get_unprojected_nodes(conn: &Connection) -> Result<Vec<KnowledgeNode>, CoreError> {
1468 let mut stmt = conn.prepare(
1469 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1470 FROM nodes WHERE status = 'active' AND projected_at IS NULL
1471 ORDER BY confidence DESC",
1472 )?;
1473 let rows = stmt.query_map([], |row| {
1474 Ok(KnowledgeNode {
1475 id: row.get(0)?,
1476 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1477 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1478 project_id: row.get(3)?,
1479 content: row.get(4)?,
1480 confidence: row.get(5)?,
1481 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1482 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1483 .unwrap_or_default()
1484 .with_timezone(&Utc),
1485 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1486 .unwrap_or_default()
1487 .with_timezone(&Utc),
1488 projected_at: row.get(9)?,
1489 pr_url: row.get(10)?,
1490 })
1491 })?;
1492 let mut nodes = Vec::new();
1493 for row in rows {
1494 nodes.push(row?);
1495 }
1496 Ok(nodes)
1497}
1498
1499pub fn get_projected_nodes_for_scope(
1501 conn: &Connection,
1502 scope: &NodeScope,
1503 project_id: Option<&str>,
1504) -> Result<Vec<KnowledgeNode>, CoreError> {
1505 let mut stmt = conn.prepare(
1506 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1507 FROM nodes
1508 WHERE status = 'active'
1509 AND projected_at IS NOT NULL
1510 AND scope = ?1
1511 AND type IN ('rule', 'directive', 'preference')
1512 AND (?2 IS NULL OR project_id = ?2)
1513 ORDER BY confidence DESC",
1514 )?;
1515 let scope_str = scope.to_string();
1516 let rows = stmt.query_map(params![scope_str, project_id], |row| {
1517 Ok(KnowledgeNode {
1518 id: row.get(0)?,
1519 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1520 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1521 project_id: row.get(3)?,
1522 content: row.get(4)?,
1523 confidence: row.get(5)?,
1524 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1525 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1526 .unwrap_or_default()
1527 .with_timezone(&Utc),
1528 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1529 .unwrap_or_default()
1530 .with_timezone(&Utc),
1531 projected_at: row.get(9)?,
1532 pr_url: row.get(10)?,
1533 })
1534 })?;
1535 let mut nodes = Vec::new();
1536 for row in rows {
1537 nodes.push(row?);
1538 }
1539 Ok(nodes)
1540}
1541
1542pub fn mark_node_projected(conn: &Connection, id: &str) -> Result<(), CoreError> {
1544 conn.execute(
1545 "UPDATE nodes SET projected_at = ?1 WHERE id = ?2",
1546 params![Utc::now().to_rfc3339(), id],
1547 )?;
1548 Ok(())
1549}
1550
1551pub fn mark_node_projected_with_pr(conn: &Connection, id: &str, pr_url: &str) -> Result<(), CoreError> {
1553 conn.execute(
1554 "UPDATE nodes SET projected_at = ?1, pr_url = ?2 WHERE id = ?3",
1555 params![Utc::now().to_rfc3339(), pr_url, id],
1556 )?;
1557 Ok(())
1558}
1559
1560pub fn get_nodes_with_pr(conn: &Connection) -> Result<Vec<KnowledgeNode>, CoreError> {
1562 let mut stmt = conn.prepare(
1563 "SELECT id, type, scope, project_id, content, confidence, status, created_at, updated_at, projected_at, pr_url
1564 FROM nodes WHERE pr_url IS NOT NULL",
1565 )?;
1566 let rows = stmt.query_map([], |row| {
1567 Ok(KnowledgeNode {
1568 id: row.get(0)?,
1569 node_type: NodeType::from_str(&row.get::<_, String>(1)?),
1570 scope: NodeScope::from_str(&row.get::<_, String>(2)?),
1571 project_id: row.get(3)?,
1572 content: row.get(4)?,
1573 confidence: row.get(5)?,
1574 status: NodeStatus::from_str(&row.get::<_, String>(6)?),
1575 created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
1576 .unwrap_or_default()
1577 .with_timezone(&Utc),
1578 updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?)
1579 .unwrap_or_default()
1580 .with_timezone(&Utc),
1581 projected_at: row.get(9)?,
1582 pr_url: row.get(10)?,
1583 })
1584 })?;
1585 let mut nodes = Vec::new();
1586 for row in rows {
1587 nodes.push(row?);
1588 }
1589 Ok(nodes)
1590}
1591
1592pub fn clear_node_pr(conn: &Connection, pr_url: &str) -> Result<(), CoreError> {
1594 conn.execute(
1595 "UPDATE nodes SET pr_url = NULL WHERE pr_url = ?1",
1596 params![pr_url],
1597 )?;
1598 Ok(())
1599}
1600
1601pub fn dismiss_nodes_for_pr(conn: &Connection, pr_url: &str) -> Result<(), CoreError> {
1603 conn.execute(
1604 "UPDATE nodes SET status = 'dismissed', pr_url = NULL WHERE pr_url = ?1",
1605 params![pr_url],
1606 )?;
1607 Ok(())
1608}
1609
1610pub fn get_all_projects(conn: &Connection) -> Result<Vec<KnowledgeProject>, CoreError> {
1611 let mut stmt = conn.prepare(
1612 "SELECT id, path, remote_url, agent_type, last_seen FROM projects ORDER BY last_seen DESC",
1613 )?;
1614 let rows = stmt.query_map([], |row| {
1615 Ok(KnowledgeProject {
1616 id: row.get(0)?,
1617 path: row.get(1)?,
1618 remote_url: row.get(2)?,
1619 agent_type: row.get(3)?,
1620 last_seen: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
1621 .unwrap_or_default()
1622 .with_timezone(&Utc),
1623 })
1624 })?;
1625 let mut projects = Vec::new();
1626 for row in rows {
1627 projects.push(row?);
1628 }
1629 Ok(projects)
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634 use super::*;
1635 use crate::models::*;
1636
1637 fn test_db() -> Connection {
1638 let conn = Connection::open_in_memory().unwrap();
1639 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
1640 migrate(&conn).unwrap();
1641 conn
1642 }
1643
1644 fn test_pattern(id: &str, description: &str) -> Pattern {
1645 Pattern {
1646 id: id.to_string(),
1647 pattern_type: PatternType::RepetitiveInstruction,
1648 description: description.to_string(),
1649 confidence: 0.85,
1650 times_seen: 1,
1651 first_seen: Utc::now(),
1652 last_seen: Utc::now(),
1653 last_projected: None,
1654 status: PatternStatus::Discovered,
1655 source_sessions: vec!["sess-1".to_string()],
1656 related_files: vec![],
1657 suggested_content: "Always do X".to_string(),
1658 suggested_target: SuggestedTarget::ClaudeMd,
1659 project: Some("/test/project".to_string()),
1660 generation_failed: false,
1661 }
1662 }
1663
1664 #[test]
1665 fn test_insert_and_get_pattern() {
1666 let conn = test_db();
1667 let pattern = test_pattern("pat-1", "Use uv for Python packages");
1668 insert_pattern(&conn, &pattern).unwrap();
1669
1670 let patterns = get_all_patterns(&conn, None).unwrap();
1671 assert_eq!(patterns.len(), 1);
1672 assert_eq!(patterns[0].id, "pat-1");
1673 assert_eq!(patterns[0].description, "Use uv for Python packages");
1674 assert!((patterns[0].confidence - 0.85).abs() < f64::EPSILON);
1675 }
1676
1677 #[test]
1678 fn test_pattern_merge_update() {
1679 let conn = test_db();
1680 let pattern = test_pattern("pat-1", "Use uv for Python packages");
1681 insert_pattern(&conn, &pattern).unwrap();
1682
1683 update_pattern_merge(
1684 &conn,
1685 "pat-1",
1686 &["sess-2".to_string(), "sess-3".to_string()],
1687 0.92,
1688 Utc::now(),
1689 2,
1690 )
1691 .unwrap();
1692
1693 let patterns = get_all_patterns(&conn, None).unwrap();
1694 assert_eq!(patterns[0].times_seen, 3);
1695 assert!((patterns[0].confidence - 0.92).abs() < f64::EPSILON);
1696 assert_eq!(patterns[0].source_sessions.len(), 3);
1697 }
1698
1699 #[test]
1700 fn test_get_patterns_by_status() {
1701 let conn = test_db();
1702 let p1 = test_pattern("pat-1", "Pattern one");
1703 let mut p2 = test_pattern("pat-2", "Pattern two");
1704 p2.status = PatternStatus::Active;
1705 insert_pattern(&conn, &p1).unwrap();
1706 insert_pattern(&conn, &p2).unwrap();
1707
1708 let discovered = get_patterns(&conn, &["discovered"], None).unwrap();
1709 assert_eq!(discovered.len(), 1);
1710 assert_eq!(discovered[0].id, "pat-1");
1711
1712 let active = get_patterns(&conn, &["active"], None).unwrap();
1713 assert_eq!(active.len(), 1);
1714 assert_eq!(active[0].id, "pat-2");
1715
1716 let both = get_patterns(&conn, &["discovered", "active"], None).unwrap();
1717 assert_eq!(both.len(), 2);
1718 }
1719
1720 #[test]
1721 fn test_analyzed_session_tracking() {
1722 let conn = test_db();
1723 assert!(!is_session_analyzed(&conn, "sess-1").unwrap());
1724
1725 record_analyzed_session(&conn, "sess-1", "/test").unwrap();
1726 assert!(is_session_analyzed(&conn, "sess-1").unwrap());
1727 assert!(!is_session_analyzed(&conn, "sess-2").unwrap());
1728 }
1729
1730 #[test]
1731 fn test_sessions_for_analysis() {
1732 let conn = test_db();
1733
1734 let session = IngestedSession {
1736 session_id: "sess-1".to_string(),
1737 project: "/test".to_string(),
1738 session_path: "/tmp/test.jsonl".to_string(),
1739 file_size: 100,
1740 file_mtime: "2026-01-01T00:00:00Z".to_string(),
1741 ingested_at: Utc::now(),
1742 };
1743 record_ingested_session(&conn, &session).unwrap();
1744
1745 let since = Utc::now() - chrono::Duration::days(14);
1747 let pending = get_sessions_for_analysis(&conn, None, &since, false).unwrap();
1748 assert_eq!(pending.len(), 1);
1749
1750 record_analyzed_session(&conn, "sess-1", "/test").unwrap();
1752 let pending = get_sessions_for_analysis(&conn, None, &since, false).unwrap();
1753 assert_eq!(pending.len(), 0);
1754
1755 let pending = get_sessions_for_analysis(&conn, None, &since, true).unwrap();
1757 assert_eq!(pending.len(), 1);
1758 }
1759
1760 #[test]
1761 fn test_insert_and_check_projection() {
1762 let conn = test_db();
1763 let pattern = test_pattern("pat-1", "Use uv");
1764 insert_pattern(&conn, &pattern).unwrap();
1765
1766 assert!(!has_projection_for_pattern(&conn, "pat-1").unwrap());
1767
1768 let proj = Projection {
1769 id: "proj-1".to_string(),
1770 pattern_id: "pat-1".to_string(),
1771 target_type: "claude_md".to_string(),
1772 target_path: "/test/CLAUDE.md".to_string(),
1773 content: "Always use uv".to_string(),
1774 applied_at: Utc::now(),
1775 pr_url: None,
1776 status: ProjectionStatus::Applied,
1777 };
1778 insert_projection(&conn, &proj).unwrap();
1779
1780 assert!(has_projection_for_pattern(&conn, "pat-1").unwrap());
1781 assert!(!has_projection_for_pattern(&conn, "pat-2").unwrap());
1782 }
1783
1784 #[test]
1785 fn test_update_pattern_status() {
1786 let conn = test_db();
1787 let pattern = test_pattern("pat-1", "Test pattern");
1788 insert_pattern(&conn, &pattern).unwrap();
1789
1790 update_pattern_status(&conn, "pat-1", &PatternStatus::Active).unwrap();
1791 let patterns = get_patterns(&conn, &["active"], None).unwrap();
1792 assert_eq!(patterns.len(), 1);
1793 assert_eq!(patterns[0].id, "pat-1");
1794 }
1795
1796 #[test]
1797 fn test_set_generation_failed() {
1798 let conn = test_db();
1799 let pattern = test_pattern("pat-1", "Test pattern");
1800 insert_pattern(&conn, &pattern).unwrap();
1801
1802 assert!(!get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1803
1804 set_generation_failed(&conn, "pat-1", true).unwrap();
1805 assert!(get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1806
1807 set_generation_failed(&conn, "pat-1", false).unwrap();
1808 assert!(!get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1809 }
1810
1811 #[test]
1812 fn test_projections_nudged_column_defaults_to_zero() {
1813 let conn = test_db();
1814
1815 conn.prepare("SELECT nudged FROM projections").unwrap();
1817
1818 let pattern = test_pattern("pat-1", "Test pattern");
1820 insert_pattern(&conn, &pattern).unwrap();
1821
1822 let proj = Projection {
1823 id: "proj-1".to_string(),
1824 pattern_id: "pat-1".to_string(),
1825 target_type: "claude_md".to_string(),
1826 target_path: "/test/CLAUDE.md".to_string(),
1827 content: "Always use uv".to_string(),
1828 applied_at: Utc::now(),
1829 pr_url: None,
1830 status: ProjectionStatus::Applied,
1831 };
1832 insert_projection(&conn, &proj).unwrap();
1833
1834 let nudged: i64 = conn
1835 .query_row(
1836 "SELECT nudged FROM projections WHERE id = 'proj-1'",
1837 [],
1838 |row| row.get(0),
1839 )
1840 .unwrap();
1841 assert_eq!(nudged, 0, "nudged column should default to 0");
1842 }
1843
1844 #[test]
1847 fn test_last_applied_at_empty() {
1848 let conn = test_db();
1849 let result = last_applied_at(&conn).unwrap();
1850 assert_eq!(result, None);
1851 }
1852
1853 #[test]
1854 fn test_last_applied_at_returns_max() {
1855 let conn = test_db();
1856
1857 let p1 = test_pattern("pat-1", "Pattern one");
1859 let p2 = test_pattern("pat-2", "Pattern two");
1860 insert_pattern(&conn, &p1).unwrap();
1861 insert_pattern(&conn, &p2).unwrap();
1862
1863 let earlier = chrono::DateTime::parse_from_rfc3339("2026-01-10T00:00:00Z")
1865 .unwrap()
1866 .with_timezone(&Utc);
1867 let later = chrono::DateTime::parse_from_rfc3339("2026-02-15T12:00:00Z")
1868 .unwrap()
1869 .with_timezone(&Utc);
1870
1871 let proj1 = Projection {
1872 id: "proj-1".to_string(),
1873 pattern_id: "pat-1".to_string(),
1874 target_type: "Skill".to_string(),
1875 target_path: "/path/a".to_string(),
1876 content: "content a".to_string(),
1877 applied_at: earlier,
1878 pr_url: None,
1879 status: ProjectionStatus::Applied,
1880 };
1881 let proj2 = Projection {
1882 id: "proj-2".to_string(),
1883 pattern_id: "pat-2".to_string(),
1884 target_type: "Skill".to_string(),
1885 target_path: "/path/b".to_string(),
1886 content: "content b".to_string(),
1887 applied_at: later,
1888 pr_url: None,
1889 status: ProjectionStatus::Applied,
1890 };
1891 insert_projection(&conn, &proj1).unwrap();
1892 insert_projection(&conn, &proj2).unwrap();
1893
1894 let result = last_applied_at(&conn).unwrap();
1895 assert!(result.is_some());
1896 let max_ts = result.unwrap();
1898 assert!(max_ts.contains("2026-02-15"), "Expected later timestamp, got: {}", max_ts);
1899 }
1900
1901 #[test]
1902 fn test_has_unanalyzed_sessions_empty() {
1903 let conn = test_db();
1904 assert!(!has_unanalyzed_sessions(&conn).unwrap());
1905 }
1906
1907 #[test]
1908 fn test_has_unanalyzed_sessions_with_new_session() {
1909 let conn = test_db();
1910
1911 let session = IngestedSession {
1912 session_id: "sess-1".to_string(),
1913 project: "/test".to_string(),
1914 session_path: "/tmp/test.jsonl".to_string(),
1915 file_size: 100,
1916 file_mtime: "2026-01-01T00:00:00Z".to_string(),
1917 ingested_at: Utc::now(),
1918 };
1919 record_ingested_session(&conn, &session).unwrap();
1920
1921 assert!(has_unanalyzed_sessions(&conn).unwrap());
1922 }
1923
1924 #[test]
1925 fn test_has_unanalyzed_sessions_after_analysis() {
1926 let conn = test_db();
1927
1928 let session = IngestedSession {
1929 session_id: "sess-1".to_string(),
1930 project: "/test".to_string(),
1931 session_path: "/tmp/test.jsonl".to_string(),
1932 file_size: 100,
1933 file_mtime: "2026-01-01T00:00:00Z".to_string(),
1934 ingested_at: Utc::now(),
1935 };
1936 record_ingested_session(&conn, &session).unwrap();
1937 record_analyzed_session(&conn, "sess-1", "/test").unwrap();
1938
1939 assert!(!has_unanalyzed_sessions(&conn).unwrap());
1940 }
1941
1942 #[test]
1943 fn test_has_unprojected_patterns_empty() {
1944 let conn = test_db();
1945 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1946 }
1947
1948 #[test]
1949 fn test_has_unprojected_patterns_with_discovered() {
1950 let conn = test_db();
1951
1952 let pattern = test_pattern("pat-1", "Use uv for Python");
1953 insert_pattern(&conn, &pattern).unwrap();
1954
1955 assert!(has_unprojected_patterns(&conn, 0.0).unwrap());
1956 }
1957
1958 #[test]
1959 fn test_has_unprojected_patterns_after_projection() {
1960 let conn = test_db();
1961
1962 let pattern = test_pattern("pat-1", "Use uv for Python");
1963 insert_pattern(&conn, &pattern).unwrap();
1964
1965 let proj = Projection {
1966 id: "proj-1".to_string(),
1967 pattern_id: "pat-1".to_string(),
1968 target_type: "Skill".to_string(),
1969 target_path: "/path".to_string(),
1970 content: "content".to_string(),
1971 applied_at: Utc::now(),
1972 pr_url: Some("https://github.com/test/pull/1".to_string()),
1973 status: ProjectionStatus::Applied,
1974 };
1975 insert_projection(&conn, &proj).unwrap();
1976
1977 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1978 }
1979
1980 #[test]
1981 fn test_has_unprojected_patterns_excludes_generation_failed() {
1982 let conn = test_db();
1983
1984 let pattern = test_pattern("pat-1", "Use uv for Python");
1985 insert_pattern(&conn, &pattern).unwrap();
1986 set_generation_failed(&conn, "pat-1", true).unwrap();
1987
1988 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1989 }
1990
1991 #[test]
1992 fn test_has_unprojected_patterns_excludes_dbonly() {
1993 let conn = test_db();
1994
1995 let mut pattern = test_pattern("pat-1", "Internal tracking only");
1996 pattern.suggested_target = SuggestedTarget::DbOnly;
1997 insert_pattern(&conn, &pattern).unwrap();
1998
1999 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2000 }
2001
2002 #[test]
2003 fn test_auto_apply_data_triggers_full_flow() {
2004 let conn = test_db();
2005
2006 assert!(!has_unanalyzed_sessions(&conn).unwrap());
2008 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2009
2010 let session = IngestedSession {
2012 session_id: "sess-1".to_string(),
2013 project: "/proj".to_string(),
2014 session_path: "/path/sess".to_string(),
2015 file_size: 100,
2016 file_mtime: "2025-01-01T00:00:00Z".to_string(),
2017 ingested_at: Utc::now(),
2018 };
2019 record_ingested_session(&conn, &session).unwrap();
2020 assert!(has_unanalyzed_sessions(&conn).unwrap());
2021
2022 record_analyzed_session(&conn, "sess-1", "/proj").unwrap();
2024 assert!(!has_unanalyzed_sessions(&conn).unwrap());
2025
2026 let p = test_pattern("pat-1", "Always use cargo fmt");
2027 insert_pattern(&conn, &p).unwrap();
2028 assert!(has_unprojected_patterns(&conn, 0.0).unwrap());
2029
2030 let proj = Projection {
2032 id: "proj-1".to_string(),
2033 pattern_id: "pat-1".to_string(),
2034 target_type: "Skill".to_string(),
2035 target_path: "/skills/cargo-fmt.md".to_string(),
2036 content: "skill content".to_string(),
2037 applied_at: Utc::now(),
2038 pr_url: Some("https://github.com/test/pull/42".to_string()),
2039 status: ProjectionStatus::Applied,
2040 };
2041 insert_projection(&conn, &proj).unwrap();
2042 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2043 }
2044
2045 #[test]
2046 fn test_get_last_nudge_at_empty() {
2047 let conn = test_db();
2048 assert!(get_last_nudge_at(&conn).unwrap().is_none());
2049 }
2050
2051 #[test]
2052 fn test_unanalyzed_session_count() {
2053 let conn = test_db();
2054 assert_eq!(unanalyzed_session_count(&conn).unwrap(), 0);
2055
2056 for i in 1..=3 {
2058 let session = IngestedSession {
2059 session_id: format!("sess-{i}"),
2060 project: "/proj".to_string(),
2061 session_path: format!("/path/sess-{i}"),
2062 file_size: 100,
2063 file_mtime: "2025-01-01T00:00:00Z".to_string(),
2064 ingested_at: Utc::now(),
2065 };
2066 record_ingested_session(&conn, &session).unwrap();
2067 }
2068 assert_eq!(unanalyzed_session_count(&conn).unwrap(), 3);
2069
2070 record_analyzed_session(&conn, "sess-1", "/proj").unwrap();
2072 assert_eq!(unanalyzed_session_count(&conn).unwrap(), 2);
2073 }
2074
2075 #[test]
2076 fn test_set_and_get_last_nudge_at() {
2077 let conn = test_db();
2078 let now = Utc::now();
2079 set_last_nudge_at(&conn, &now).unwrap();
2080 let result = get_last_nudge_at(&conn).unwrap().unwrap();
2081 assert_eq!(
2083 result.format("%Y-%m-%dT%H:%M:%S").to_string(),
2084 now.format("%Y-%m-%dT%H:%M:%S").to_string()
2085 );
2086 }
2087
2088 #[test]
2089 fn test_projection_status_column_exists() {
2090 let conn = test_db();
2091 let pattern = test_pattern("pat-1", "Test");
2092 insert_pattern(&conn, &pattern).unwrap();
2093
2094 let proj = Projection {
2095 id: "proj-1".to_string(),
2096 pattern_id: "pat-1".to_string(),
2097 target_type: "skill".to_string(),
2098 target_path: "/test/skill.md".to_string(),
2099 content: "content".to_string(),
2100 applied_at: Utc::now(),
2101 pr_url: None,
2102 status: ProjectionStatus::PendingReview,
2103 };
2104 insert_projection(&conn, &proj).unwrap();
2105
2106 let status: String = conn
2107 .query_row(
2108 "SELECT status FROM projections WHERE id = 'proj-1'",
2109 [],
2110 |row| row.get(0),
2111 )
2112 .unwrap();
2113 assert_eq!(status, "pending_review");
2114 }
2115
2116 #[test]
2117 fn test_existing_projections_default_to_applied() {
2118 let conn = Connection::open_in_memory().unwrap();
2120 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2121
2122 conn.execute_batch(
2124 "CREATE TABLE patterns (
2125 id TEXT PRIMARY KEY, pattern_type TEXT NOT NULL, description TEXT NOT NULL,
2126 confidence REAL NOT NULL, times_seen INTEGER NOT NULL DEFAULT 1,
2127 first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, last_projected TEXT,
2128 status TEXT NOT NULL DEFAULT 'discovered', source_sessions TEXT NOT NULL,
2129 related_files TEXT NOT NULL, suggested_content TEXT NOT NULL,
2130 suggested_target TEXT NOT NULL, project TEXT,
2131 generation_failed INTEGER NOT NULL DEFAULT 0
2132 );
2133 CREATE TABLE projections (
2134 id TEXT PRIMARY KEY, pattern_id TEXT NOT NULL REFERENCES patterns(id),
2135 target_type TEXT NOT NULL, target_path TEXT NOT NULL, content TEXT NOT NULL,
2136 applied_at TEXT NOT NULL, pr_url TEXT, nudged INTEGER NOT NULL DEFAULT 0
2137 );
2138 CREATE TABLE analyzed_sessions (session_id TEXT PRIMARY KEY, project TEXT NOT NULL, analyzed_at TEXT NOT NULL);
2139 CREATE TABLE ingested_sessions (session_id TEXT PRIMARY KEY, project TEXT NOT NULL, session_path TEXT NOT NULL, file_size INTEGER NOT NULL, file_mtime TEXT NOT NULL, ingested_at TEXT NOT NULL);
2140 PRAGMA user_version = 1;",
2141 ).unwrap();
2142
2143 conn.execute(
2145 "INSERT INTO patterns (id, pattern_type, description, confidence, first_seen, last_seen, status, source_sessions, related_files, suggested_content, suggested_target)
2146 VALUES ('pat-1', 'workflow_pattern', 'Test', 0.8, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'discovered', '[]', '[]', 'content', 'skill')",
2147 [],
2148 ).unwrap();
2149
2150 conn.execute(
2152 "INSERT INTO projections (id, pattern_id, target_type, target_path, content, applied_at)
2153 VALUES ('proj-old', 'pat-1', 'skill', '/path', 'content', '2026-01-01T00:00:00Z')",
2154 [],
2155 ).unwrap();
2156
2157 migrate(&conn).unwrap();
2159
2160 let status: String = conn
2162 .query_row("SELECT status FROM projections WHERE id = 'proj-old'", [], |row| row.get(0))
2163 .unwrap();
2164 assert_eq!(status, "applied");
2165 }
2166
2167 #[test]
2168 fn test_get_pending_review_projections() {
2169 let conn = test_db();
2170 let p1 = test_pattern("pat-1", "Pattern one");
2171 let p2 = test_pattern("pat-2", "Pattern two");
2172 insert_pattern(&conn, &p1).unwrap();
2173 insert_pattern(&conn, &p2).unwrap();
2174
2175 let proj1 = Projection {
2177 id: "proj-1".to_string(),
2178 pattern_id: "pat-1".to_string(),
2179 target_type: "skill".to_string(),
2180 target_path: "/test/a.md".to_string(),
2181 content: "content a".to_string(),
2182 applied_at: Utc::now(),
2183 pr_url: None,
2184 status: ProjectionStatus::PendingReview,
2185 };
2186 let proj2 = Projection {
2187 id: "proj-2".to_string(),
2188 pattern_id: "pat-2".to_string(),
2189 target_type: "skill".to_string(),
2190 target_path: "/test/b.md".to_string(),
2191 content: "content b".to_string(),
2192 applied_at: Utc::now(),
2193 pr_url: None,
2194 status: ProjectionStatus::Applied,
2195 };
2196 insert_projection(&conn, &proj1).unwrap();
2197 insert_projection(&conn, &proj2).unwrap();
2198
2199 let pending = get_pending_review_projections(&conn).unwrap();
2200 assert_eq!(pending.len(), 1);
2201 assert_eq!(pending[0].id, "proj-1");
2202 }
2203
2204 #[test]
2205 fn test_update_projection_status() {
2206 let conn = test_db();
2207 let p = test_pattern("pat-1", "Pattern");
2208 insert_pattern(&conn, &p).unwrap();
2209
2210 let proj = Projection {
2211 id: "proj-1".to_string(),
2212 pattern_id: "pat-1".to_string(),
2213 target_type: "skill".to_string(),
2214 target_path: "/test.md".to_string(),
2215 content: "content".to_string(),
2216 applied_at: Utc::now(),
2217 pr_url: None,
2218 status: ProjectionStatus::PendingReview,
2219 };
2220 insert_projection(&conn, &proj).unwrap();
2221
2222 update_projection_status(&conn, "proj-1", &ProjectionStatus::Applied).unwrap();
2223
2224 let status: String = conn
2225 .query_row("SELECT status FROM projections WHERE id = 'proj-1'", [], |row| row.get(0))
2226 .unwrap();
2227 assert_eq!(status, "applied");
2228 }
2229
2230 #[test]
2231 fn test_delete_projection() {
2232 let conn = test_db();
2233 let p = test_pattern("pat-1", "Pattern");
2234 insert_pattern(&conn, &p).unwrap();
2235
2236 let proj = Projection {
2237 id: "proj-1".to_string(),
2238 pattern_id: "pat-1".to_string(),
2239 target_type: "skill".to_string(),
2240 target_path: "/test.md".to_string(),
2241 content: "content".to_string(),
2242 applied_at: Utc::now(),
2243 pr_url: None,
2244 status: ProjectionStatus::PendingReview,
2245 };
2246 insert_projection(&conn, &proj).unwrap();
2247 assert!(has_projection_for_pattern(&conn, "pat-1").unwrap());
2248
2249 delete_projection(&conn, "proj-1").unwrap();
2250 assert!(!has_projection_for_pattern(&conn, "pat-1").unwrap());
2251 }
2252
2253 #[test]
2254 fn test_get_projections_with_pr_url() {
2255 let conn = test_db();
2256 let p1 = test_pattern("pat-1", "Pattern one");
2257 let p2 = test_pattern("pat-2", "Pattern two");
2258 insert_pattern(&conn, &p1).unwrap();
2259 insert_pattern(&conn, &p2).unwrap();
2260
2261 let proj1 = Projection {
2262 id: "proj-1".to_string(),
2263 pattern_id: "pat-1".to_string(),
2264 target_type: "skill".to_string(),
2265 target_path: "/a.md".to_string(),
2266 content: "a".to_string(),
2267 applied_at: Utc::now(),
2268 pr_url: Some("https://github.com/test/pull/1".to_string()),
2269 status: ProjectionStatus::Applied,
2270 };
2271 let proj2 = Projection {
2272 id: "proj-2".to_string(),
2273 pattern_id: "pat-2".to_string(),
2274 target_type: "skill".to_string(),
2275 target_path: "/b.md".to_string(),
2276 content: "b".to_string(),
2277 applied_at: Utc::now(),
2278 pr_url: None,
2279 status: ProjectionStatus::Applied,
2280 };
2281 insert_projection(&conn, &proj1).unwrap();
2282 insert_projection(&conn, &proj2).unwrap();
2283
2284 let with_pr = get_applied_projections_with_pr(&conn).unwrap();
2285 assert_eq!(with_pr.len(), 1);
2286 assert_eq!(with_pr[0].pr_url, Some("https://github.com/test/pull/1".to_string()));
2287 }
2288
2289 #[test]
2290 fn test_get_projected_pattern_ids_by_status() {
2291 let conn = test_db();
2292 let p1 = test_pattern("pat-1", "Pattern one");
2293 let p2 = test_pattern("pat-2", "Pattern two");
2294 insert_pattern(&conn, &p1).unwrap();
2295 insert_pattern(&conn, &p2).unwrap();
2296
2297 let proj1 = Projection {
2298 id: "proj-1".to_string(),
2299 pattern_id: "pat-1".to_string(),
2300 target_type: "skill".to_string(),
2301 target_path: "/a.md".to_string(),
2302 content: "a".to_string(),
2303 applied_at: Utc::now(),
2304 pr_url: None,
2305 status: ProjectionStatus::Applied,
2306 };
2307 let proj2 = Projection {
2308 id: "proj-2".to_string(),
2309 pattern_id: "pat-2".to_string(),
2310 target_type: "skill".to_string(),
2311 target_path: "/b.md".to_string(),
2312 content: "b".to_string(),
2313 applied_at: Utc::now(),
2314 pr_url: None,
2315 status: ProjectionStatus::PendingReview,
2316 };
2317 insert_projection(&conn, &proj1).unwrap();
2318 insert_projection(&conn, &proj2).unwrap();
2319
2320 let ids = get_projected_pattern_ids_by_status(&conn, &[ProjectionStatus::Applied, ProjectionStatus::PendingReview]).unwrap();
2321 assert_eq!(ids.len(), 2);
2322
2323 let ids_applied_only = get_projected_pattern_ids_by_status(&conn, &[ProjectionStatus::Applied]).unwrap();
2324 assert_eq!(ids_applied_only.len(), 1);
2325 assert!(ids_applied_only.contains("pat-1"));
2326 }
2327
2328 #[test]
2329 fn test_has_unprojected_patterns_excludes_dismissed() {
2330 let conn = test_db();
2331
2332 let mut pattern = test_pattern("pat-1", "Dismissed pattern");
2333 pattern.status = PatternStatus::Dismissed;
2334 insert_pattern(&conn, &pattern).unwrap();
2335
2336 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2337 }
2338
2339 #[test]
2340 fn test_has_unprojected_patterns_excludes_pending_review() {
2341 let conn = test_db();
2342
2343 let pattern = test_pattern("pat-1", "Pattern with pending review");
2344 insert_pattern(&conn, &pattern).unwrap();
2345
2346 let proj = Projection {
2348 id: "proj-1".to_string(),
2349 pattern_id: "pat-1".to_string(),
2350 target_type: "skill".to_string(),
2351 target_path: "/test.md".to_string(),
2352 content: "content".to_string(),
2353 applied_at: Utc::now(),
2354 pr_url: None,
2355 status: ProjectionStatus::PendingReview,
2356 };
2357 insert_projection(&conn, &proj).unwrap();
2358
2359 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2361 }
2362
2363 #[test]
2364 fn test_v4_migration_creates_tables() {
2365 let conn = Connection::open_in_memory().unwrap();
2366 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2367 migrate(&conn).unwrap();
2368
2369 let version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
2370 assert_eq!(version, 6);
2371
2372 let count: i64 = conn.query_row(
2374 "SELECT COUNT(*) FROM nodes WHERE 1=0", [], |row| row.get(0)
2375 ).unwrap();
2376 assert_eq!(count, 0);
2377
2378 let count: i64 = conn.query_row(
2380 "SELECT COUNT(*) FROM edges WHERE 1=0", [], |row| row.get(0)
2381 ).unwrap();
2382 assert_eq!(count, 0);
2383
2384 let count: i64 = conn.query_row(
2386 "SELECT COUNT(*) FROM projects WHERE 1=0", [], |row| row.get(0)
2387 ).unwrap();
2388 assert_eq!(count, 0);
2389 }
2390
2391 #[test]
2394 fn test_insert_and_get_node() {
2395 let conn = Connection::open_in_memory().unwrap();
2396 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2397 migrate(&conn).unwrap();
2398
2399 let node = KnowledgeNode {
2400 id: "node-1".to_string(),
2401 node_type: NodeType::Rule,
2402 scope: NodeScope::Project,
2403 project_id: Some("my-app".to_string()),
2404 content: "Always run tests".to_string(),
2405 confidence: 0.85,
2406 status: NodeStatus::Active,
2407 created_at: Utc::now(),
2408 updated_at: Utc::now(),
2409 projected_at: None,
2410 pr_url: None,
2411 };
2412
2413 insert_node(&conn, &node).unwrap();
2414 let retrieved = get_node(&conn, "node-1").unwrap().unwrap();
2415 assert_eq!(retrieved.content, "Always run tests");
2416 assert_eq!(retrieved.node_type, NodeType::Rule);
2417 assert_eq!(retrieved.scope, NodeScope::Project);
2418 assert_eq!(retrieved.confidence, 0.85);
2419 }
2420
2421 #[test]
2422 fn test_get_nodes_by_scope_and_status() {
2423 let conn = Connection::open_in_memory().unwrap();
2424 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2425 migrate(&conn).unwrap();
2426
2427 let now = Utc::now();
2428 for (i, scope) in [NodeScope::Global, NodeScope::Project, NodeScope::Global].iter().enumerate() {
2429 let node = KnowledgeNode {
2430 id: format!("node-{i}"),
2431 node_type: NodeType::Rule,
2432 scope: scope.clone(),
2433 project_id: if *scope == NodeScope::Project { Some("my-app".to_string()) } else { None },
2434 content: format!("Rule {i}"),
2435 confidence: 0.8,
2436 status: NodeStatus::Active,
2437 created_at: now,
2438 updated_at: now,
2439 projected_at: None,
2440 pr_url: None,
2441 };
2442 insert_node(&conn, &node).unwrap();
2443 }
2444
2445 let global_nodes = get_nodes_by_scope(&conn, &NodeScope::Global, None, &[NodeStatus::Active]).unwrap();
2446 assert_eq!(global_nodes.len(), 2);
2447
2448 let project_nodes = get_nodes_by_scope(&conn, &NodeScope::Project, Some("my-app"), &[NodeStatus::Active]).unwrap();
2449 assert_eq!(project_nodes.len(), 1);
2450 }
2451
2452 #[test]
2453 fn test_update_node_confidence() {
2454 let conn = Connection::open_in_memory().unwrap();
2455 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2456 migrate(&conn).unwrap();
2457
2458 let node = KnowledgeNode {
2459 id: "node-1".to_string(),
2460 node_type: NodeType::Pattern,
2461 scope: NodeScope::Project,
2462 project_id: Some("my-app".to_string()),
2463 content: "Forgets tests".to_string(),
2464 confidence: 0.5,
2465 status: NodeStatus::Active,
2466 created_at: Utc::now(),
2467 updated_at: Utc::now(),
2468 projected_at: None,
2469 pr_url: None,
2470 };
2471 insert_node(&conn, &node).unwrap();
2472
2473 update_node_confidence(&conn, "node-1", 0.75).unwrap();
2474 let updated = get_node(&conn, "node-1").unwrap().unwrap();
2475 assert_eq!(updated.confidence, 0.75);
2476 }
2477
2478 #[test]
2479 fn test_update_node_status() {
2480 let conn = Connection::open_in_memory().unwrap();
2481 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2482 migrate(&conn).unwrap();
2483
2484 let node = KnowledgeNode {
2485 id: "node-1".to_string(),
2486 node_type: NodeType::Rule,
2487 scope: NodeScope::Global,
2488 project_id: None,
2489 content: "Use snake_case".to_string(),
2490 confidence: 0.9,
2491 status: NodeStatus::PendingReview,
2492 created_at: Utc::now(),
2493 updated_at: Utc::now(),
2494 projected_at: None,
2495 pr_url: None,
2496 };
2497 insert_node(&conn, &node).unwrap();
2498
2499 update_node_status(&conn, "node-1", &NodeStatus::Active).unwrap();
2500 let updated = get_node(&conn, "node-1").unwrap().unwrap();
2501 assert_eq!(updated.status, NodeStatus::Active);
2502 }
2503
2504 #[test]
2505 fn test_v4_migration_from_v3() {
2506 let conn = Connection::open_in_memory().unwrap();
2507 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2508
2509 conn.execute_batch("
2511 CREATE TABLE patterns (id TEXT PRIMARY KEY, pattern_type TEXT NOT NULL, description TEXT NOT NULL, confidence REAL NOT NULL, times_seen INTEGER NOT NULL DEFAULT 1, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, last_projected TEXT, status TEXT NOT NULL DEFAULT 'discovered', source_sessions TEXT NOT NULL, related_files TEXT NOT NULL, suggested_content TEXT NOT NULL, suggested_target TEXT NOT NULL, project TEXT, generation_failed INTEGER NOT NULL DEFAULT 0);
2512 CREATE TABLE projections (id TEXT PRIMARY KEY, pattern_id TEXT NOT NULL, target_type TEXT NOT NULL, target_path TEXT NOT NULL, content TEXT NOT NULL, applied_at TEXT NOT NULL, pr_url TEXT, nudged INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'applied');
2513 CREATE TABLE analyzed_sessions (session_id TEXT PRIMARY KEY, project TEXT NOT NULL, analyzed_at TEXT NOT NULL);
2514 CREATE TABLE ingested_sessions (session_id TEXT PRIMARY KEY, project TEXT NOT NULL, session_path TEXT NOT NULL, file_size INTEGER NOT NULL, file_mtime TEXT NOT NULL, ingested_at TEXT NOT NULL);
2515 CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2516 ").unwrap();
2517 conn.pragma_update(None, "user_version", 3).unwrap();
2518
2519 migrate(&conn).unwrap();
2521
2522 let version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
2523 assert_eq!(version, 6);
2524
2525 conn.query_row("SELECT COUNT(*) FROM nodes WHERE 1=0", [], |row| row.get::<_, i64>(0)).unwrap();
2527 conn.query_row("SELECT COUNT(*) FROM edges WHERE 1=0", [], |row| row.get::<_, i64>(0)).unwrap();
2528 conn.query_row("SELECT COUNT(*) FROM projects WHERE 1=0", [], |row| row.get::<_, i64>(0)).unwrap();
2529
2530 conn.query_row("SELECT COUNT(*) FROM patterns WHERE 1=0", [], |row| row.get::<_, i64>(0)).unwrap();
2532 }
2533
2534 #[test]
2537 fn test_insert_and_get_edges() {
2538 let conn = Connection::open_in_memory().unwrap();
2539 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2540 migrate(&conn).unwrap();
2541
2542 let now = Utc::now();
2543 let node1 = KnowledgeNode {
2544 id: "node-1".to_string(), node_type: NodeType::Pattern,
2545 scope: NodeScope::Project, project_id: Some("app".to_string()),
2546 content: "Pattern A".to_string(), confidence: 0.5,
2547 status: NodeStatus::Active, created_at: now, updated_at: now,
2548 projected_at: None, pr_url: None,
2549 };
2550 let node2 = KnowledgeNode {
2551 id: "node-2".to_string(), node_type: NodeType::Rule,
2552 scope: NodeScope::Project, project_id: Some("app".to_string()),
2553 content: "Rule B".to_string(), confidence: 0.8,
2554 status: NodeStatus::Active, created_at: now, updated_at: now,
2555 projected_at: None, pr_url: None,
2556 };
2557 insert_node(&conn, &node1).unwrap();
2558 insert_node(&conn, &node2).unwrap();
2559
2560 let edge = KnowledgeEdge {
2561 source_id: "node-1".to_string(),
2562 target_id: "node-2".to_string(),
2563 edge_type: EdgeType::DerivedFrom,
2564 created_at: now,
2565 };
2566 insert_edge(&conn, &edge).unwrap();
2567
2568 let edges = get_edges_from(&conn, "node-1").unwrap();
2569 assert_eq!(edges.len(), 1);
2570 assert_eq!(edges[0].target_id, "node-2");
2571 assert_eq!(edges[0].edge_type, EdgeType::DerivedFrom);
2572
2573 let edges_to = get_edges_to(&conn, "node-2").unwrap();
2574 assert_eq!(edges_to.len(), 1);
2575 assert_eq!(edges_to[0].source_id, "node-1");
2576 }
2577
2578 #[test]
2579 fn test_supersede_node_archives_old() {
2580 let conn = Connection::open_in_memory().unwrap();
2581 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2582 migrate(&conn).unwrap();
2583
2584 let now = Utc::now();
2585 let old_node = KnowledgeNode {
2586 id: "old".to_string(), node_type: NodeType::Rule,
2587 scope: NodeScope::Global, project_id: None,
2588 content: "Old rule".to_string(), confidence: 0.8,
2589 status: NodeStatus::Active, created_at: now, updated_at: now,
2590 projected_at: None, pr_url: None,
2591 };
2592 let new_node = KnowledgeNode {
2593 id: "new".to_string(), node_type: NodeType::Rule,
2594 scope: NodeScope::Global, project_id: None,
2595 content: "New rule".to_string(), confidence: 0.85,
2596 status: NodeStatus::Active, created_at: now, updated_at: now,
2597 projected_at: None, pr_url: None,
2598 };
2599 insert_node(&conn, &old_node).unwrap();
2600 insert_node(&conn, &new_node).unwrap();
2601
2602 supersede_node(&conn, "new", "old").unwrap();
2603
2604 let old = get_node(&conn, "old").unwrap().unwrap();
2605 assert_eq!(old.status, NodeStatus::Archived);
2606
2607 let edges = get_edges_from(&conn, "new").unwrap();
2608 assert_eq!(edges.len(), 1);
2609 assert_eq!(edges[0].edge_type, EdgeType::Supersedes);
2610 assert_eq!(edges[0].target_id, "old");
2611 }
2612
2613 #[test]
2616 fn test_upsert_and_get_project() {
2617 let conn = Connection::open_in_memory().unwrap();
2618 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2619 migrate(&conn).unwrap();
2620
2621 let project = KnowledgeProject {
2622 id: "my-app".to_string(),
2623 path: "/home/user/my-app".to_string(),
2624 remote_url: Some("git@github.com:user/my-app.git".to_string()),
2625 agent_type: "claude_code".to_string(),
2626 last_seen: Utc::now(),
2627 };
2628 upsert_project(&conn, &project).unwrap();
2629
2630 let retrieved = get_project(&conn, "my-app").unwrap().unwrap();
2631 assert_eq!(retrieved.path, "/home/user/my-app");
2632 assert_eq!(retrieved.remote_url.unwrap(), "git@github.com:user/my-app.git");
2633 }
2634
2635 #[test]
2636 fn test_get_project_by_remote_url() {
2637 let conn = Connection::open_in_memory().unwrap();
2638 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2639 migrate(&conn).unwrap();
2640
2641 let project = KnowledgeProject {
2642 id: "my-app".to_string(),
2643 path: "/old/path".to_string(),
2644 remote_url: Some("git@github.com:user/my-app.git".to_string()),
2645 agent_type: "claude_code".to_string(),
2646 last_seen: Utc::now(),
2647 };
2648 upsert_project(&conn, &project).unwrap();
2649
2650 let found = get_project_by_remote_url(&conn, "git@github.com:user/my-app.git").unwrap();
2651 assert!(found.is_some());
2652 assert_eq!(found.unwrap().id, "my-app");
2653 }
2654
2655 #[test]
2656 fn test_get_all_projects() {
2657 let conn = Connection::open_in_memory().unwrap();
2658 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2659 migrate(&conn).unwrap();
2660
2661 for name in ["app-1", "app-2"] {
2662 let project = KnowledgeProject {
2663 id: name.to_string(),
2664 path: format!("/home/{name}"),
2665 remote_url: None,
2666 agent_type: "claude_code".to_string(),
2667 last_seen: Utc::now(),
2668 };
2669 upsert_project(&conn, &project).unwrap();
2670 }
2671
2672 let projects = get_all_projects(&conn).unwrap();
2673 assert_eq!(projects.len(), 2);
2674 }
2675
2676 #[test]
2677 fn test_generate_project_slug() {
2678 assert_eq!(generate_project_slug("/home/user/my-rust-app"), "my-rust-app");
2679 assert_eq!(generate_project_slug("/home/user/My App"), "my-app");
2680 assert_eq!(generate_project_slug("/"), "unnamed-project");
2681 }
2682
2683 #[test]
2684 fn test_migrate_patterns_to_nodes() {
2685 let conn = Connection::open_in_memory().unwrap();
2686 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2687 migrate(&conn).unwrap();
2688
2689 let now = Utc::now().to_rfc3339();
2691 conn.execute(
2692 "INSERT INTO patterns (id, pattern_type, description, confidence, times_seen, first_seen, last_seen, status, source_sessions, related_files, suggested_content, suggested_target, project, generation_failed)
2693 VALUES (?1, ?2, ?3, ?4, 2, ?5, ?5, 'active', '[]', '[]', 'content', ?6, ?7, 0)",
2694 params!["p1", "repetitive_instruction", "Always run tests", 0.85, &now, "claude_md", "my-app"],
2695 ).unwrap();
2696 conn.execute(
2697 "INSERT INTO patterns (id, pattern_type, description, confidence, times_seen, first_seen, last_seen, status, source_sessions, related_files, suggested_content, suggested_target, project, generation_failed)
2698 VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'discovered', '[]', '[]', 'content', ?6, ?7, 0)",
2699 params!["p2", "recurring_mistake", "Forgets imports", 0.6, &now, "skill", "my-app"],
2700 ).unwrap();
2701 conn.execute(
2702 "INSERT INTO patterns (id, pattern_type, description, confidence, times_seen, first_seen, last_seen, status, source_sessions, related_files, suggested_content, suggested_target, project, generation_failed)
2703 VALUES (?1, ?2, ?3, ?4, 3, ?5, ?5, 'active', '[]', '[]', 'Always use snake_case', ?6, ?7, 0)",
2704 params!["p3", "repetitive_instruction", "Always use snake_case", 0.9, &now, "claude_md", "my-app"],
2705 ).unwrap();
2706
2707 let count = migrate_patterns_to_nodes(&conn).unwrap();
2708 assert_eq!(count, 3);
2709
2710 let node1 = get_node(&conn, "migrated-p1").unwrap().unwrap();
2712 assert_eq!(node1.node_type, NodeType::Rule);
2713 assert_eq!(node1.scope, NodeScope::Project);
2714
2715 let node2 = get_node(&conn, "migrated-p2").unwrap().unwrap();
2717 assert_eq!(node2.node_type, NodeType::Pattern);
2718
2719 let node3 = get_node(&conn, "migrated-p3").unwrap().unwrap();
2721 assert_eq!(node3.node_type, NodeType::Directive);
2722 }
2723
2724 #[test]
2725 fn test_apply_graph_operations() {
2726 let conn = Connection::open_in_memory().unwrap();
2727 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2728 migrate(&conn).unwrap();
2729
2730 let ops = vec![
2731 GraphOperation::CreateNode {
2732 node_type: NodeType::Rule,
2733 scope: NodeScope::Project,
2734 project_id: Some("my-app".to_string()),
2735 content: "Always run tests".to_string(),
2736 confidence: 0.85,
2737 },
2738 GraphOperation::CreateNode {
2739 node_type: NodeType::Pattern,
2740 scope: NodeScope::Global,
2741 project_id: None,
2742 content: "Prefers TDD".to_string(),
2743 confidence: 0.6,
2744 },
2745 ];
2746
2747 let result = apply_graph_operations(&conn, &ops).unwrap();
2748 assert_eq!(result.nodes_created, 2);
2749
2750 let nodes = get_nodes_by_scope(&conn, &NodeScope::Project, Some("my-app"), &[NodeStatus::Active]).unwrap();
2751 assert_eq!(nodes.len(), 1);
2752 assert_eq!(nodes[0].content, "Always run tests");
2753 }
2754
2755 #[test]
2756 fn test_apply_graph_operations_update() {
2757 let conn = Connection::open_in_memory().unwrap();
2758 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2759 migrate(&conn).unwrap();
2760
2761 let node = KnowledgeNode {
2762 id: "node-1".to_string(),
2763 node_type: NodeType::Pattern,
2764 scope: NodeScope::Project,
2765 project_id: Some("app".to_string()),
2766 content: "Old content".to_string(),
2767 confidence: 0.5,
2768 status: NodeStatus::Active,
2769 created_at: Utc::now(),
2770 updated_at: Utc::now(),
2771 projected_at: None,
2772 pr_url: None,
2773 };
2774 insert_node(&conn, &node).unwrap();
2775
2776 let ops = vec![
2777 GraphOperation::UpdateNode {
2778 id: "node-1".to_string(),
2779 confidence: Some(0.8),
2780 content: Some("Updated content".to_string()),
2781 },
2782 ];
2783
2784 let result = apply_graph_operations(&conn, &ops).unwrap();
2785 assert_eq!(result.nodes_updated, 1);
2786
2787 let updated = get_node(&conn, "node-1").unwrap().unwrap();
2788 assert_eq!(updated.confidence, 0.8);
2789 assert_eq!(updated.content, "Updated content");
2790 }
2791
2792 #[test]
2793 fn test_get_nodes_by_status() {
2794 let conn = test_db();
2795
2796 let active_node = KnowledgeNode {
2797 id: "n1".to_string(),
2798 node_type: NodeType::Rule,
2799 scope: NodeScope::Project,
2800 project_id: Some("my-app".to_string()),
2801 content: "Always run tests".to_string(),
2802 confidence: 0.85,
2803 status: NodeStatus::Active,
2804 created_at: Utc::now(),
2805 updated_at: Utc::now(),
2806 projected_at: None,
2807 pr_url: None,
2808 };
2809 let pending_node = KnowledgeNode {
2810 id: "n2".to_string(),
2811 node_type: NodeType::Directive,
2812 scope: NodeScope::Global,
2813 project_id: None,
2814 content: "Use snake_case".to_string(),
2815 confidence: 0.9,
2816 status: NodeStatus::PendingReview,
2817 created_at: Utc::now(),
2818 updated_at: Utc::now(),
2819 projected_at: None,
2820 pr_url: None,
2821 };
2822 let dismissed_node = KnowledgeNode {
2823 id: "n3".to_string(),
2824 node_type: NodeType::Pattern,
2825 scope: NodeScope::Project,
2826 project_id: Some("my-app".to_string()),
2827 content: "Old pattern".to_string(),
2828 confidence: 0.5,
2829 status: NodeStatus::Dismissed,
2830 created_at: Utc::now(),
2831 updated_at: Utc::now(),
2832 projected_at: None,
2833 pr_url: None,
2834 };
2835
2836 insert_node(&conn, &active_node).unwrap();
2837 insert_node(&conn, &pending_node).unwrap();
2838 insert_node(&conn, &dismissed_node).unwrap();
2839
2840 let pending = get_nodes_by_status(&conn, &NodeStatus::PendingReview).unwrap();
2841 assert_eq!(pending.len(), 1);
2842 assert_eq!(pending[0].id, "n2");
2843
2844 let active = get_nodes_by_status(&conn, &NodeStatus::Active).unwrap();
2845 assert_eq!(active.len(), 1);
2846 assert_eq!(active[0].id, "n1");
2847
2848 let pending2 = KnowledgeNode {
2849 id: "n4".to_string(),
2850 node_type: NodeType::Rule,
2851 scope: NodeScope::Project,
2852 project_id: Some("other".to_string()),
2853 content: "Second pending".to_string(),
2854 confidence: 0.95,
2855 status: NodeStatus::PendingReview,
2856 created_at: Utc::now(),
2857 updated_at: Utc::now(),
2858 projected_at: None,
2859 pr_url: None,
2860 };
2861 insert_node(&conn, &pending2).unwrap();
2862 let pending_all = get_nodes_by_status(&conn, &NodeStatus::PendingReview).unwrap();
2863 assert_eq!(pending_all.len(), 2);
2864 assert_eq!(pending_all[0].id, "n4"); }
2866
2867 #[test]
2868 fn test_migrate_v4_to_v5_adds_projection_columns() {
2869 let conn = Connection::open_in_memory().unwrap();
2870 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2871 migrate(&conn).unwrap();
2872
2873 let node = KnowledgeNode {
2875 id: "test-1".to_string(),
2876 node_type: NodeType::Rule,
2877 scope: NodeScope::Global,
2878 project_id: None,
2879 content: "test rule".to_string(),
2880 confidence: 0.8,
2881 status: NodeStatus::Active,
2882 created_at: Utc::now(),
2883 updated_at: Utc::now(),
2884 projected_at: None,
2885 pr_url: None,
2886 };
2887 insert_node(&conn, &node).unwrap();
2888
2889 let retrieved = get_node(&conn, "test-1").unwrap().unwrap();
2890 assert!(retrieved.projected_at.is_none());
2891 assert!(retrieved.pr_url.is_none());
2892
2893 let version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
2895 assert_eq!(version, 6);
2896 }
2897
2898 fn test_node(id: &str, status: NodeStatus, projected_at: Option<String>, pr_url: Option<String>) -> KnowledgeNode {
2899 KnowledgeNode {
2900 id: id.to_string(),
2901 node_type: NodeType::Rule,
2902 scope: NodeScope::Global,
2903 project_id: None,
2904 content: format!("Content for {}", id),
2905 confidence: 0.8,
2906 status,
2907 created_at: Utc::now(),
2908 updated_at: Utc::now(),
2909 projected_at,
2910 pr_url,
2911 }
2912 }
2913
2914 #[test]
2915 fn test_get_unprojected_nodes() {
2916 let conn = test_db();
2917
2918 let active_unprojected = test_node("n1", NodeStatus::Active, None, None);
2920 let active_projected = test_node("n2", NodeStatus::Active, Some(Utc::now().to_rfc3339()), None);
2922 let pending = test_node("n3", NodeStatus::PendingReview, None, None);
2924
2925 insert_node(&conn, &active_unprojected).unwrap();
2926 insert_node(&conn, &active_projected).unwrap();
2927 insert_node(&conn, &pending).unwrap();
2928
2929 let nodes = get_unprojected_nodes(&conn).unwrap();
2930 assert_eq!(nodes.len(), 1);
2931 assert_eq!(nodes[0].id, "n1");
2932 }
2933
2934 #[test]
2935 fn test_mark_node_projected() {
2936 let conn = test_db();
2937
2938 let node = test_node("n1", NodeStatus::Active, None, None);
2939 insert_node(&conn, &node).unwrap();
2940
2941 mark_node_projected(&conn, "n1").unwrap();
2942
2943 let retrieved = get_node(&conn, "n1").unwrap().unwrap();
2944 assert!(retrieved.projected_at.is_some());
2945 assert!(retrieved.pr_url.is_none());
2946 }
2947
2948 #[test]
2949 fn test_mark_node_projected_with_pr() {
2950 let conn = test_db();
2951
2952 let node = test_node("n1", NodeStatus::Active, None, None);
2953 insert_node(&conn, &node).unwrap();
2954
2955 mark_node_projected_with_pr(&conn, "n1", "https://github.com/test/pull/42").unwrap();
2956
2957 let retrieved = get_node(&conn, "n1").unwrap().unwrap();
2958 assert!(retrieved.projected_at.is_some());
2959 assert_eq!(retrieved.pr_url, Some("https://github.com/test/pull/42".to_string()));
2960 }
2961
2962 #[test]
2963 fn test_dismiss_nodes_for_pr() {
2964 let conn = test_db();
2965
2966 let pr_url = "https://github.com/test/pull/99";
2967 let node1 = test_node("n1", NodeStatus::Active, Some(Utc::now().to_rfc3339()), Some(pr_url.to_string()));
2968 let node2 = test_node("n2", NodeStatus::Active, Some(Utc::now().to_rfc3339()), Some(pr_url.to_string()));
2969
2970 insert_node(&conn, &node1).unwrap();
2971 insert_node(&conn, &node2).unwrap();
2972
2973 dismiss_nodes_for_pr(&conn, pr_url).unwrap();
2974
2975 let n1 = get_node(&conn, "n1").unwrap().unwrap();
2976 let n2 = get_node(&conn, "n2").unwrap().unwrap();
2977
2978 assert_eq!(n1.status, NodeStatus::Dismissed);
2979 assert!(n1.pr_url.is_none());
2980 assert_eq!(n2.status, NodeStatus::Dismissed);
2981 assert!(n2.pr_url.is_none());
2982 }
2983
2984 #[test]
2985 fn test_clear_node_pr() {
2986 let conn = test_db();
2987
2988 let pr_url = "https://github.com/test/pull/7";
2989 let node = test_node("n1", NodeStatus::Active, Some(Utc::now().to_rfc3339()), Some(pr_url.to_string()));
2990 insert_node(&conn, &node).unwrap();
2991
2992 clear_node_pr(&conn, pr_url).unwrap();
2993
2994 let retrieved = get_node(&conn, "n1").unwrap().unwrap();
2995 assert!(retrieved.pr_url.is_none());
2996 assert_eq!(retrieved.status, NodeStatus::Active);
2997 }
2998
2999 #[test]
3000 fn test_get_projected_nodes_for_scope_global() {
3001 let conn = test_db();
3002
3003 let projected_rule = KnowledgeNode {
3005 id: "n1".to_string(),
3006 node_type: NodeType::Rule,
3007 scope: NodeScope::Global,
3008 project_id: None,
3009 content: "Always write tests first".to_string(),
3010 confidence: 0.9,
3011 status: NodeStatus::Active,
3012 created_at: Utc::now(),
3013 updated_at: Utc::now(),
3014 projected_at: Some(Utc::now().to_rfc3339()),
3015 pr_url: None,
3016 };
3017
3018 let unprojected_rule = KnowledgeNode {
3020 id: "n2".to_string(),
3021 node_type: NodeType::Rule,
3022 scope: NodeScope::Global,
3023 project_id: None,
3024 content: "Use cargo fmt before committing".to_string(),
3025 confidence: 0.8,
3026 status: NodeStatus::Active,
3027 created_at: Utc::now(),
3028 updated_at: Utc::now(),
3029 projected_at: None,
3030 pr_url: None,
3031 };
3032
3033 let projected_skill = KnowledgeNode {
3035 id: "n3".to_string(),
3036 node_type: NodeType::Skill,
3037 scope: NodeScope::Global,
3038 project_id: None,
3039 content: "Some skill content".to_string(),
3040 confidence: 0.85,
3041 status: NodeStatus::Active,
3042 created_at: Utc::now(),
3043 updated_at: Utc::now(),
3044 projected_at: Some(Utc::now().to_rfc3339()),
3045 pr_url: None,
3046 };
3047
3048 insert_node(&conn, &projected_rule).unwrap();
3049 insert_node(&conn, &unprojected_rule).unwrap();
3050 insert_node(&conn, &projected_skill).unwrap();
3051
3052 let nodes = get_projected_nodes_for_scope(&conn, &NodeScope::Global, None).unwrap();
3053 assert_eq!(nodes.len(), 1);
3054 assert_eq!(nodes[0].id, "n1");
3055 }
3056
3057 #[test]
3058 fn test_get_projected_nodes_for_scope_project() {
3059 let conn = test_db();
3060
3061 let my_project_node = KnowledgeNode {
3063 id: "n1".to_string(),
3064 node_type: NodeType::Directive,
3065 scope: NodeScope::Project,
3066 project_id: Some("my-project".to_string()),
3067 content: "Always use the project linter config".to_string(),
3068 confidence: 0.75,
3069 status: NodeStatus::Active,
3070 created_at: Utc::now(),
3071 updated_at: Utc::now(),
3072 projected_at: Some(Utc::now().to_rfc3339()),
3073 pr_url: None,
3074 };
3075
3076 let other_project_node = KnowledgeNode {
3078 id: "n2".to_string(),
3079 node_type: NodeType::Preference,
3080 scope: NodeScope::Project,
3081 project_id: Some("other-project".to_string()),
3082 content: "Prefer tabs over spaces".to_string(),
3083 confidence: 0.7,
3084 status: NodeStatus::Active,
3085 created_at: Utc::now(),
3086 updated_at: Utc::now(),
3087 projected_at: Some(Utc::now().to_rfc3339()),
3088 pr_url: None,
3089 };
3090
3091 insert_node(&conn, &my_project_node).unwrap();
3092 insert_node(&conn, &other_project_node).unwrap();
3093
3094 let nodes = get_projected_nodes_for_scope(&conn, &NodeScope::Project, Some("my-project")).unwrap();
3095 assert_eq!(nodes.len(), 1);
3096 assert_eq!(nodes[0].id, "n1");
3097 }
3098}