Skip to main content

retro_core/
db.rs

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
15/// Open (or create) the retro database with WAL mode enabled.
16pub fn open_db(path: &Path) -> Result<Connection, CoreError> {
17    let conn = Connection::open(path)?;
18
19    // Enable WAL mode for concurrent access
20    conn.pragma_update(None, "journal_mode", "WAL")?;
21
22    // Run migrations
23    migrate(&conn)?;
24
25    Ok(conn)
26}
27
28/// Initialize schema on an existing connection (for testing with in-memory DBs).
29pub 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 existing v1 patterns to v2 nodes
155        migrate_patterns_to_nodes(conn)?;
156
157        conn.pragma_update(None, "user_version", 4)?;
158    }
159
160    if current_version < 5 {
161        // For databases upgraded from v4, we need to add the new columns.
162        // For fresh installs, v4 CREATE TABLE already includes them.
163        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        // v6: Clean up v1 leftovers now that v2 pipeline is primary.
177        // - Archive all v1 patterns (they've been migrated to nodes in v4)
178        // - Delete pending_review projections (v2 uses nodes table for review)
179        // - Remove bogus project entries with path "/" (from sessions ingested without project context)
180        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
192/// Check if a session has already been ingested and is up-to-date.
193pub 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
216/// Record a session as ingested.
217pub 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
236/// Get the count of ingested sessions.
237pub 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
245/// Get the count of ingested sessions for a specific project.
246pub 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
258/// Get the count of analyzed sessions.
259pub 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
267/// Get the count of patterns by status.
268pub 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
277/// Get the most recent ingestion timestamp.
278pub 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
287/// Get the most recent analysis timestamp.
288pub 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
297/// Get the most recent projection (apply) timestamp.
298pub 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
307/// Check if there are ingested sessions that haven't been analyzed yet.
308pub 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
319/// Count ingested sessions that haven't been analyzed yet.
320pub 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
331/// Check if there are patterns eligible for projection that haven't been projected yet.
332/// Mirrors the gating logic in `get_qualifying_patterns()`: excludes patterns with
333/// generation_failed=true, suggested_target='db_only', or confidence below threshold.
334/// The confidence threshold is the sole quality gate (no times_seen requirement).
335pub 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
350/// Get the last nudge timestamp from metadata.
351pub 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
369/// Set the last nudge timestamp in metadata.
370pub 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
378/// Get a value from the metadata table by key.
379pub 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
390/// Set a value in the metadata table (insert or replace).
391pub 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
399/// Verify the database is using WAL mode.
400pub 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
405/// Get all distinct projects from ingested sessions.
406pub 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
416// ── Pattern operations ──
417
418const 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
420/// Insert a new pattern into the database.
421pub 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
451/// Update an existing pattern with new evidence (merge).
452pub 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    // Load existing source_sessions and merge
461    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
494/// Get patterns filtered by status and optionally by project.
495pub 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
544/// Get all patterns, optionally filtered by project.
545pub 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
607// ── Analyzed session tracking ──
608
609/// Record a session as analyzed.
610pub 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
623/// Check if a session has been analyzed.
624pub 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
633/// Get ingested sessions for analysis within the time window.
634/// When `rolling_window` is true, returns ALL sessions in the window (re-analyzes everything).
635/// When false, only returns sessions not yet in `analyzed_sessions` (analyze-once).
636pub 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
706// ── Projection operations ──
707
708/// Insert a new projection record.
709pub 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
727/// Check if a pattern already has an active projection.
728pub 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
737/// Get the set of all pattern IDs that already have projections.
738pub 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
749/// Update a pattern's status.
750pub 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
762/// Set or clear the generation_failed flag on a pattern.
763pub 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
775/// Get all projections for active patterns (for staleness detection).
776pub 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
812/// Update a pattern's last_projected timestamp to now.
813pub 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
821/// Get all projections with pending_review status.
822pub 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
856/// Update a projection's status.
857pub 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
869/// Delete a projection record.
870pub 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
875/// Get applied projections that have a PR URL (for sync).
876pub 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
909/// Get pattern IDs that have projections with specific statuses.
910pub 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
932/// Update a projection's PR URL.
933pub 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
945// ── Knowledge graph: Migration ──
946
947/// Migrate v1 patterns to v2 knowledge nodes. Returns number of nodes created.
948/// Safe to call multiple times — skips patterns that already have corresponding nodes.
949pub 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        // Skip if already migrated
967        if get_node(conn, &node_id)?.is_some() {
968            continue;
969        }
970
971        // Determine node type using the spec's deterministic mapping
972        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
1015// ── Knowledge graph: Node operations ──
1016
1017pub 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
1115/// Get all nodes with a given status, ordered by confidence DESC.
1116pub 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
1175// ── Knowledge graph: Edge operations ──
1176
1177pub 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
1241/// Mark new_id as superseding old_id: archives old node and creates supersedes edge.
1242pub 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/// Result of applying a batch of graph operations.
1255#[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
1263/// Apply a batch of graph operations to the database.
1264pub 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
1315// ── Knowledge graph: Project operations ──
1316
1317/// Generate a human-readable slug from a repository path.
1318pub 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        // Collapse consecutive hyphens
1334        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
1351/// Generate a unique project slug, appending -2, -3, etc. if needed.
1352pub 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
1403/// Find an existing project by its filesystem path.
1404pub 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
1422/// Register a project if not already registered (by path). Returns the project ID.
1423/// If already registered, updates last_seen and remote_url.
1424pub fn ensure_project_registered(conn: &Connection, path: &str) -> Result<String, CoreError> {
1425    // Check if already registered by path
1426    if let Some(existing) = get_project_by_path(conn, path)? {
1427        // Update last_seen
1428        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    // New project — generate slug and register
1436    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
1466/// Get active nodes that haven't been projected yet, ordered by confidence DESC.
1467pub 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
1499/// Get active nodes that have been projected for a given scope, filtering to Rule/Directive/Preference types.
1500pub 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
1542/// Mark a node as projected (direct write, no PR).
1543pub 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
1551/// Mark a node as projected via PR.
1552pub 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
1560/// Get all nodes with an associated PR URL.
1561pub 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
1592/// Clear PR URL from nodes after merge.
1593pub 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
1601/// Dismiss all nodes for a closed PR.
1602pub 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        // Record an ingested session
1735        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        // It should appear in sessions for analysis (non-rolling)
1746        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        // After marking as analyzed, it should not appear in non-rolling mode
1751        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        // But it SHOULD still appear in rolling window mode
1756        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        // Verify the nudged column exists by preparing a statement that references it
1816        conn.prepare("SELECT nudged FROM projections").unwrap();
1817
1818        // Insert a projection without specifying nudged — should default to 0
1819        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    // ── Tests for auto-apply pipeline DB functions ──
1845
1846    #[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        // Insert two patterns to serve as FK targets
1858        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        // Insert projections with different timestamps
1864        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        // The max should be the later timestamp
1897        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        // Initially: no data, no triggers
2007        assert!(!has_unanalyzed_sessions(&conn).unwrap());
2008        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
2009
2010        // Step 1: Ingest creates sessions → triggers analyze
2011        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        // Step 2: After analysis → sessions marked, patterns created → triggers apply
2023        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        // Step 3: After apply → projection created with PR URL
2031        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        // Add 3 sessions
2057        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        // Analyze one
2071        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        // Compare to second precision (DB stores RFC 3339)
2082        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        // Simulate a v2 database with existing projections
2119        let conn = Connection::open_in_memory().unwrap();
2120        conn.pragma_update(None, "journal_mode", "WAL").unwrap();
2121
2122        // Create v1 schema manually
2123        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        // Insert a pattern first (FK target)
2144        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        // Insert an old-style projection (no status column)
2151        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        // Now run migration (open_db equivalent)
2158        migrate(&conn).unwrap();
2159
2160        // Old projection should have status = 'applied'
2161        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        // One pending, one applied
2176        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        // Create a pending_review projection
2347        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        // Pattern already has a pending_review projection — should NOT be "unprojected"
2360        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        // Verify nodes table exists with correct columns
2373        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        // Verify edges table exists
2379        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        // Verify projects table exists
2385        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    // ── Node CRUD tests ──
2392
2393    #[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        // Manually create a v3-state database (v1+v2+v3 tables, user_version=3)
2510        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        // Now run migrate — should only add v4 tables
2520        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        // v4 tables exist
2526        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        // Old tables still exist
2531        conn.query_row("SELECT COUNT(*) FROM patterns WHERE 1=0", [], |row| row.get::<_, i64>(0)).unwrap();
2532    }
2533
2534    // ── Edge CRUD tests ──
2535
2536    #[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    // ── Project CRUD tests ──
2614
2615    #[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        // Insert v1 patterns
2690        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        // p1: RepetitiveInstruction + ClaudeMd -> rule
2711        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        // p2: RecurringMistake -> pattern
2716        let node2 = get_node(&conn, "migrated-p2").unwrap().unwrap();
2717        assert_eq!(node2.node_type, NodeType::Pattern);
2718
2719        // p3: confidence >= 0.85 + "always" in content -> directive (override)
2720        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"); // Higher confidence first
2865    }
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        // Insert a node — should support new columns
2874        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        // Verify schema version
2894        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        // Active node with no projected_at — should be returned
2919        let active_unprojected = test_node("n1", NodeStatus::Active, None, None);
2920        // Active node with projected_at set — should NOT be returned
2921        let active_projected = test_node("n2", NodeStatus::Active, Some(Utc::now().to_rfc3339()), None);
2922        // PendingReview node with no projected_at — should NOT be returned (wrong status)
2923        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        // Active projected Rule (global) — should be returned
3004        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        // Active unprojected Rule (global) — should NOT be returned
3019        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        // Active projected Skill (global) — should NOT be returned (wrong type)
3034        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        // Active projected Directive for "my-project" — should be returned
3062        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        // Active projected Preference for "other-project" — should NOT be returned
3077        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}