Skip to main content

retro_core/
db.rs

1use crate::errors::CoreError;
2use crate::models::{IngestedSession, Pattern, PatternStatus, PatternType, Projection, ProjectionStatus, SuggestedTarget};
3use chrono::{DateTime, Utc};
4pub use rusqlite::Connection;
5use rusqlite::params;
6use rusqlite::OptionalExtension;
7use std::path::Path;
8
9const SCHEMA_VERSION: u32 = 3;
10
11/// Open (or create) the retro database with WAL mode enabled.
12pub fn open_db(path: &Path) -> Result<Connection, CoreError> {
13    let conn = Connection::open(path)?;
14
15    // Enable WAL mode for concurrent access
16    conn.pragma_update(None, "journal_mode", "WAL")?;
17
18    // Run migrations
19    migrate(&conn)?;
20
21    Ok(conn)
22}
23
24fn migrate(conn: &Connection) -> Result<(), CoreError> {
25    let current_version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
26
27    if current_version < 1 {
28        conn.execute_batch(
29            "
30            CREATE TABLE IF NOT EXISTS patterns (
31                id TEXT PRIMARY KEY,
32                pattern_type TEXT NOT NULL,
33                description TEXT NOT NULL,
34                confidence REAL NOT NULL,
35                times_seen INTEGER NOT NULL DEFAULT 1,
36                first_seen TEXT NOT NULL,
37                last_seen TEXT NOT NULL,
38                last_projected TEXT,
39                status TEXT NOT NULL DEFAULT 'discovered',
40                source_sessions TEXT NOT NULL,
41                related_files TEXT NOT NULL,
42                suggested_content TEXT NOT NULL,
43                suggested_target TEXT NOT NULL,
44                project TEXT,
45                generation_failed INTEGER NOT NULL DEFAULT 0
46            );
47
48            CREATE TABLE IF NOT EXISTS projections (
49                id TEXT PRIMARY KEY,
50                pattern_id TEXT NOT NULL REFERENCES patterns(id),
51                target_type TEXT NOT NULL,
52                target_path TEXT NOT NULL,
53                content TEXT NOT NULL,
54                applied_at TEXT NOT NULL,
55                pr_url TEXT,
56                nudged INTEGER NOT NULL DEFAULT 0
57            );
58
59            CREATE TABLE IF NOT EXISTS analyzed_sessions (
60                session_id TEXT PRIMARY KEY,
61                project TEXT NOT NULL,
62                analyzed_at TEXT NOT NULL
63            );
64
65            CREATE TABLE IF NOT EXISTS ingested_sessions (
66                session_id TEXT PRIMARY KEY,
67                project TEXT NOT NULL,
68                session_path TEXT NOT NULL,
69                file_size INTEGER NOT NULL,
70                file_mtime TEXT NOT NULL,
71                ingested_at TEXT NOT NULL
72            );
73
74            CREATE INDEX IF NOT EXISTS idx_patterns_status ON patterns(status);
75            CREATE INDEX IF NOT EXISTS idx_patterns_type ON patterns(pattern_type);
76            CREATE INDEX IF NOT EXISTS idx_patterns_target ON patterns(suggested_target);
77            CREATE INDEX IF NOT EXISTS idx_patterns_project ON patterns(project);
78            CREATE INDEX IF NOT EXISTS idx_projections_pattern ON projections(pattern_id);
79            ",
80        )?;
81
82        conn.pragma_update(None, "user_version", 1)?;
83    }
84
85    if current_version < 2 {
86        conn.execute_batch(
87            "
88            CREATE TABLE IF NOT EXISTS metadata (
89                key TEXT PRIMARY KEY,
90                value TEXT NOT NULL
91            );
92            ",
93        )?;
94        conn.pragma_update(None, "user_version", 2)?;
95    }
96
97    if current_version < 3 {
98        conn.execute_batch(
99            "ALTER TABLE projections ADD COLUMN status TEXT NOT NULL DEFAULT 'applied';",
100        )?;
101        conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
102    }
103
104    Ok(())
105}
106
107/// Check if a session has already been ingested and is up-to-date.
108pub fn is_session_ingested(
109    conn: &Connection,
110    session_id: &str,
111    file_size: u64,
112    file_mtime: &str,
113) -> Result<bool, CoreError> {
114    let mut stmt = conn.prepare(
115        "SELECT file_size, file_mtime FROM ingested_sessions WHERE session_id = ?1",
116    )?;
117
118    let result = stmt.query_row(params![session_id], |row| {
119        let size: u64 = row.get(0)?;
120        let mtime: String = row.get(1)?;
121        Ok((size, mtime))
122    });
123
124    match result {
125        Ok((size, mtime)) => Ok(size == file_size && mtime == file_mtime),
126        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
127        Err(e) => Err(CoreError::Database(e.to_string())),
128    }
129}
130
131/// Record a session as ingested.
132pub fn record_ingested_session(
133    conn: &Connection,
134    session: &IngestedSession,
135) -> Result<(), CoreError> {
136    conn.execute(
137        "INSERT OR REPLACE INTO ingested_sessions (session_id, project, session_path, file_size, file_mtime, ingested_at)
138         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
139        params![
140            session.session_id,
141            session.project,
142            session.session_path,
143            session.file_size,
144            session.file_mtime,
145            session.ingested_at.to_rfc3339(),
146        ],
147    )?;
148    Ok(())
149}
150
151/// Get the count of ingested sessions.
152pub fn ingested_session_count(conn: &Connection) -> Result<u64, CoreError> {
153    let count: u64 =
154        conn.query_row("SELECT COUNT(*) FROM ingested_sessions", [], |row| {
155            row.get(0)
156        })?;
157    Ok(count)
158}
159
160/// Get the count of ingested sessions for a specific project.
161pub fn ingested_session_count_for_project(
162    conn: &Connection,
163    project: &str,
164) -> Result<u64, CoreError> {
165    let count: u64 = conn.query_row(
166        "SELECT COUNT(*) FROM ingested_sessions WHERE project = ?1",
167        params![project],
168        |row| row.get(0),
169    )?;
170    Ok(count)
171}
172
173/// Get the count of analyzed sessions.
174pub fn analyzed_session_count(conn: &Connection) -> Result<u64, CoreError> {
175    let count: u64 =
176        conn.query_row("SELECT COUNT(*) FROM analyzed_sessions", [], |row| {
177            row.get(0)
178        })?;
179    Ok(count)
180}
181
182/// Get the count of patterns by status.
183pub fn pattern_count_by_status(conn: &Connection, status: &str) -> Result<u64, CoreError> {
184    let count: u64 = conn.query_row(
185        "SELECT COUNT(*) FROM patterns WHERE status = ?1",
186        params![status],
187        |row| row.get(0),
188    )?;
189    Ok(count)
190}
191
192/// Get the most recent ingestion timestamp.
193pub fn last_ingested_at(conn: &Connection) -> Result<Option<String>, CoreError> {
194    let result = conn.query_row(
195        "SELECT MAX(ingested_at) FROM ingested_sessions",
196        [],
197        |row| row.get::<_, Option<String>>(0),
198    )?;
199    Ok(result)
200}
201
202/// Get the most recent analysis timestamp.
203pub fn last_analyzed_at(conn: &Connection) -> Result<Option<String>, CoreError> {
204    let result = conn.query_row(
205        "SELECT MAX(analyzed_at) FROM analyzed_sessions",
206        [],
207        |row| row.get::<_, Option<String>>(0),
208    )?;
209    Ok(result)
210}
211
212/// Get the most recent projection (apply) timestamp.
213pub fn last_applied_at(conn: &Connection) -> Result<Option<String>, CoreError> {
214    let result = conn.query_row(
215        "SELECT MAX(applied_at) FROM projections",
216        [],
217        |row| row.get::<_, Option<String>>(0),
218    )?;
219    Ok(result)
220}
221
222/// Check if there are ingested sessions that haven't been analyzed yet.
223pub fn has_unanalyzed_sessions(conn: &Connection) -> Result<bool, CoreError> {
224    let count: u64 = conn.query_row(
225        "SELECT COUNT(*) FROM ingested_sessions i
226         LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
227         WHERE a.session_id IS NULL",
228        [],
229        |row| row.get(0),
230    )?;
231    Ok(count > 0)
232}
233
234/// Count ingested sessions that haven't been analyzed yet.
235pub fn unanalyzed_session_count(conn: &Connection) -> Result<u64, CoreError> {
236    let count: u64 = conn.query_row(
237        "SELECT COUNT(*) FROM ingested_sessions i
238         LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
239         WHERE a.session_id IS NULL",
240        [],
241        |row| row.get(0),
242    )?;
243    Ok(count)
244}
245
246/// Check if there are patterns eligible for projection that haven't been projected yet.
247/// Mirrors the gating logic in `get_qualifying_patterns()`: excludes patterns with
248/// generation_failed=true, suggested_target='db_only', or confidence below threshold.
249/// The confidence threshold is the sole quality gate (no times_seen requirement).
250pub fn has_unprojected_patterns(conn: &Connection, confidence_threshold: f64) -> Result<bool, CoreError> {
251    let count: u64 = conn.query_row(
252        "SELECT COUNT(*) FROM patterns p
253         LEFT JOIN projections pr ON p.id = pr.pattern_id
254         WHERE pr.id IS NULL
255         AND p.status IN ('discovered', 'active')
256         AND p.generation_failed = 0
257         AND p.suggested_target != 'db_only'
258         AND p.confidence >= ?1",
259        [confidence_threshold],
260        |row| row.get(0),
261    )?;
262    Ok(count > 0)
263}
264
265/// Get the last nudge timestamp from metadata.
266pub fn get_last_nudge_at(conn: &Connection) -> Result<Option<DateTime<Utc>>, CoreError> {
267    let result: Option<String> = conn
268        .query_row(
269            "SELECT value FROM metadata WHERE key = 'last_nudge_at'",
270            [],
271            |row| row.get(0),
272        )
273        .optional()?;
274
275    match result {
276        Some(s) => match DateTime::parse_from_rfc3339(&s) {
277            Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
278            Err(_) => Ok(None),
279        },
280        None => Ok(None),
281    }
282}
283
284/// Set the last nudge timestamp in metadata.
285pub fn set_last_nudge_at(conn: &Connection, timestamp: &DateTime<Utc>) -> Result<(), CoreError> {
286    conn.execute(
287        "INSERT OR REPLACE INTO metadata (key, value) VALUES ('last_nudge_at', ?1)",
288        params![timestamp.to_rfc3339()],
289    )?;
290    Ok(())
291}
292
293/// Verify the database is using WAL mode.
294pub fn verify_wal_mode(conn: &Connection) -> Result<bool, CoreError> {
295    let mode: String = conn.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
296    Ok(mode.to_lowercase() == "wal")
297}
298
299/// Get all distinct projects from ingested sessions.
300pub fn list_projects(conn: &Connection) -> Result<Vec<String>, CoreError> {
301    let mut stmt =
302        conn.prepare("SELECT DISTINCT project FROM ingested_sessions ORDER BY project")?;
303    let projects = stmt
304        .query_map([], |row| row.get(0))?
305        .filter_map(|r| r.ok())
306        .collect();
307    Ok(projects)
308}
309
310// ── Pattern operations ──
311
312const 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";
313
314/// Insert a new pattern into the database.
315pub fn insert_pattern(conn: &Connection, pattern: &Pattern) -> Result<(), CoreError> {
316    let source_sessions =
317        serde_json::to_string(&pattern.source_sessions).unwrap_or_else(|_| "[]".to_string());
318    let related_files =
319        serde_json::to_string(&pattern.related_files).unwrap_or_else(|_| "[]".to_string());
320
321    conn.execute(
322        "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)
323         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
324        params![
325            pattern.id,
326            pattern.pattern_type.to_string(),
327            pattern.description,
328            pattern.confidence,
329            pattern.times_seen,
330            pattern.first_seen.to_rfc3339(),
331            pattern.last_seen.to_rfc3339(),
332            pattern.last_projected.map(|t| t.to_rfc3339()),
333            pattern.status.to_string(),
334            source_sessions,
335            related_files,
336            pattern.suggested_content,
337            pattern.suggested_target.to_string(),
338            pattern.project,
339            pattern.generation_failed as i32,
340        ],
341    )?;
342    Ok(())
343}
344
345/// Update an existing pattern with new evidence (merge).
346pub fn update_pattern_merge(
347    conn: &Connection,
348    id: &str,
349    new_sessions: &[String],
350    new_confidence: f64,
351    new_last_seen: DateTime<Utc>,
352    additional_times_seen: i64,
353) -> Result<(), CoreError> {
354    // Load existing source_sessions and merge
355    let existing_sessions: String = conn.query_row(
356        "SELECT source_sessions FROM patterns WHERE id = ?1",
357        params![id],
358        |row| row.get(0),
359    )?;
360
361    let mut sessions: Vec<String> =
362        serde_json::from_str(&existing_sessions).unwrap_or_default();
363    for s in new_sessions {
364        if !sessions.contains(s) {
365            sessions.push(s.clone());
366        }
367    }
368    let merged_sessions = serde_json::to_string(&sessions).unwrap_or_else(|_| "[]".to_string());
369
370    conn.execute(
371        "UPDATE patterns SET
372            confidence = MAX(confidence, ?2),
373            times_seen = times_seen + ?3,
374            last_seen = ?4,
375            source_sessions = ?5
376         WHERE id = ?1",
377        params![
378            id,
379            new_confidence,
380            additional_times_seen,
381            new_last_seen.to_rfc3339(),
382            merged_sessions,
383        ],
384    )?;
385    Ok(())
386}
387
388/// Get patterns filtered by status and optionally by project.
389pub fn get_patterns(
390    conn: &Connection,
391    statuses: &[&str],
392    project: Option<&str>,
393) -> Result<Vec<Pattern>, CoreError> {
394    if statuses.is_empty() {
395        return Ok(Vec::new());
396    }
397
398    let placeholders: Vec<String> = statuses.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
399    let status_clause = placeholders.join(", ");
400
401    let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match project {
402        Some(proj) => {
403            let q = format!(
404                "SELECT {PATTERN_COLUMNS}
405                 FROM patterns WHERE status IN ({}) AND (project = ?{} OR project IS NULL)
406                 ORDER BY confidence DESC",
407                status_clause,
408                statuses.len() + 1
409            );
410            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();
411            p.push(Box::new(proj.to_string()));
412            (q, p)
413        }
414        None => {
415            let q = format!(
416                "SELECT {PATTERN_COLUMNS}
417                 FROM patterns WHERE status IN ({})
418                 ORDER BY confidence DESC",
419                status_clause
420            );
421            let p: Vec<Box<dyn rusqlite::types::ToSql>> = statuses.iter().map(|s| Box::new(s.to_string()) as Box<dyn rusqlite::types::ToSql>).collect();
422            (q, p)
423        }
424    };
425
426    let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
427    let mut stmt = conn.prepare(&query)?;
428    let patterns = stmt
429        .query_map(params_refs.as_slice(), |row| {
430            Ok(read_pattern_row(row))
431        })?
432        .filter_map(|r| r.ok())
433        .collect();
434
435    Ok(patterns)
436}
437
438/// Get all patterns, optionally filtered by project.
439pub fn get_all_patterns(conn: &Connection, project: Option<&str>) -> Result<Vec<Pattern>, CoreError> {
440    let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match project {
441        Some(proj) => {
442            let q = format!(
443                "SELECT {PATTERN_COLUMNS}
444                 FROM patterns WHERE project = ?1 OR project IS NULL
445                 ORDER BY confidence DESC"
446            );
447            (q, vec![Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>])
448        }
449        None => {
450            let q = format!(
451                "SELECT {PATTERN_COLUMNS}
452                 FROM patterns ORDER BY confidence DESC"
453            );
454            (q, vec![])
455        }
456    };
457
458    let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
459    let mut stmt = conn.prepare(&query)?;
460    let patterns = stmt
461        .query_map(params_refs.as_slice(), |row| Ok(read_pattern_row(row)))?
462        .filter_map(|r| r.ok())
463        .collect();
464
465    Ok(patterns)
466}
467
468fn read_pattern_row(row: &rusqlite::Row<'_>) -> Pattern {
469    let source_sessions_str: String = row.get(9).unwrap_or_default();
470    let related_files_str: String = row.get(10).unwrap_or_default();
471    let first_seen_str: String = row.get(5).unwrap_or_default();
472    let last_seen_str: String = row.get(6).unwrap_or_default();
473    let last_projected_str: Option<String> = row.get(7).unwrap_or(None);
474    let gen_failed: i32 = row.get(14).unwrap_or(0);
475
476    Pattern {
477        id: row.get(0).unwrap_or_default(),
478        pattern_type: PatternType::from_str(&row.get::<_, String>(1).unwrap_or_default()),
479        description: row.get(2).unwrap_or_default(),
480        confidence: row.get(3).unwrap_or(0.0),
481        times_seen: row.get(4).unwrap_or(1),
482        first_seen: DateTime::parse_from_rfc3339(&first_seen_str)
483            .map(|d| d.with_timezone(&Utc))
484            .unwrap_or_else(|_| Utc::now()),
485        last_seen: DateTime::parse_from_rfc3339(&last_seen_str)
486            .map(|d| d.with_timezone(&Utc))
487            .unwrap_or_else(|_| Utc::now()),
488        last_projected: last_projected_str
489            .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
490            .map(|d| d.with_timezone(&Utc)),
491        status: PatternStatus::from_str(&row.get::<_, String>(8).unwrap_or_default()),
492        source_sessions: serde_json::from_str(&source_sessions_str).unwrap_or_default(),
493        related_files: serde_json::from_str(&related_files_str).unwrap_or_default(),
494        suggested_content: row.get(11).unwrap_or_default(),
495        suggested_target: SuggestedTarget::from_str(&row.get::<_, String>(12).unwrap_or_default()),
496        project: row.get(13).unwrap_or(None),
497        generation_failed: gen_failed != 0,
498    }
499}
500
501// ── Analyzed session tracking ──
502
503/// Record a session as analyzed.
504pub fn record_analyzed_session(
505    conn: &Connection,
506    session_id: &str,
507    project: &str,
508) -> Result<(), CoreError> {
509    conn.execute(
510        "INSERT OR REPLACE INTO analyzed_sessions (session_id, project, analyzed_at)
511         VALUES (?1, ?2, ?3)",
512        params![session_id, project, Utc::now().to_rfc3339()],
513    )?;
514    Ok(())
515}
516
517/// Check if a session has been analyzed.
518pub fn is_session_analyzed(conn: &Connection, session_id: &str) -> Result<bool, CoreError> {
519    let count: u64 = conn.query_row(
520        "SELECT COUNT(*) FROM analyzed_sessions WHERE session_id = ?1",
521        params![session_id],
522        |row| row.get(0),
523    )?;
524    Ok(count > 0)
525}
526
527/// Get ingested sessions for analysis within the time window.
528/// When `rolling_window` is true, returns ALL sessions in the window (re-analyzes everything).
529/// When false, only returns sessions not yet in `analyzed_sessions` (analyze-once).
530pub fn get_sessions_for_analysis(
531    conn: &Connection,
532    project: Option<&str>,
533    since: &DateTime<Utc>,
534    rolling_window: bool,
535) -> Result<Vec<IngestedSession>, CoreError> {
536    let since_str = since.to_rfc3339();
537
538    let (query, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (project, rolling_window) {
539        (Some(proj), true) => {
540            let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
541                     FROM ingested_sessions i
542                     WHERE i.project = ?1 AND i.ingested_at >= ?2
543                     ORDER BY i.ingested_at".to_string();
544            (q, vec![
545                Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>,
546                Box::new(since_str) as Box<dyn rusqlite::types::ToSql>,
547            ])
548        }
549        (Some(proj), false) => {
550            let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
551                     FROM ingested_sessions i
552                     LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
553                     WHERE a.session_id IS NULL AND i.project = ?1 AND i.ingested_at >= ?2
554                     ORDER BY i.ingested_at".to_string();
555            (q, vec![
556                Box::new(proj.to_string()) as Box<dyn rusqlite::types::ToSql>,
557                Box::new(since_str) as Box<dyn rusqlite::types::ToSql>,
558            ])
559        }
560        (None, true) => {
561            let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
562                     FROM ingested_sessions i
563                     WHERE i.ingested_at >= ?1
564                     ORDER BY i.ingested_at".to_string();
565            (q, vec![Box::new(since_str) as Box<dyn rusqlite::types::ToSql>])
566        }
567        (None, false) => {
568            let q = "SELECT i.session_id, i.project, i.session_path, i.file_size, i.file_mtime, i.ingested_at
569                     FROM ingested_sessions i
570                     LEFT JOIN analyzed_sessions a ON i.session_id = a.session_id
571                     WHERE a.session_id IS NULL AND i.ingested_at >= ?1
572                     ORDER BY i.ingested_at".to_string();
573            (q, vec![Box::new(since_str) as Box<dyn rusqlite::types::ToSql>])
574        }
575    };
576
577    let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
578    let mut stmt = conn.prepare(&query)?;
579    let sessions = stmt
580        .query_map(params_refs.as_slice(), |row| {
581            let ingested_at_str: String = row.get(5)?;
582            let ingested_at = DateTime::parse_from_rfc3339(&ingested_at_str)
583                .map(|d| d.with_timezone(&Utc))
584                .unwrap_or_else(|_| Utc::now());
585            Ok(IngestedSession {
586                session_id: row.get(0)?,
587                project: row.get(1)?,
588                session_path: row.get(2)?,
589                file_size: row.get(3)?,
590                file_mtime: row.get(4)?,
591                ingested_at,
592            })
593        })?
594        .filter_map(|r| r.ok())
595        .collect();
596
597    Ok(sessions)
598}
599
600// ── Projection operations ──
601
602/// Insert a new projection record.
603pub fn insert_projection(conn: &Connection, proj: &Projection) -> Result<(), CoreError> {
604    conn.execute(
605        "INSERT INTO projections (id, pattern_id, target_type, target_path, content, applied_at, pr_url, status)
606         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
607        params![
608            proj.id,
609            proj.pattern_id,
610            proj.target_type,
611            proj.target_path,
612            proj.content,
613            proj.applied_at.to_rfc3339(),
614            proj.pr_url,
615            proj.status.to_string(),
616        ],
617    )?;
618    Ok(())
619}
620
621/// Check if a pattern already has an active projection.
622pub fn has_projection_for_pattern(conn: &Connection, pattern_id: &str) -> Result<bool, CoreError> {
623    let count: u64 = conn.query_row(
624        "SELECT COUNT(*) FROM projections WHERE pattern_id = ?1",
625        params![pattern_id],
626        |row| row.get(0),
627    )?;
628    Ok(count > 0)
629}
630
631/// Get the set of all pattern IDs that already have projections.
632pub fn get_projected_pattern_ids(
633    conn: &Connection,
634) -> Result<std::collections::HashSet<String>, CoreError> {
635    let mut stmt = conn.prepare("SELECT DISTINCT pattern_id FROM projections")?;
636    let ids = stmt
637        .query_map([], |row| row.get(0))?
638        .filter_map(|r| r.ok())
639        .collect();
640    Ok(ids)
641}
642
643/// Update a pattern's status.
644pub fn update_pattern_status(
645    conn: &Connection,
646    id: &str,
647    status: &PatternStatus,
648) -> Result<(), CoreError> {
649    conn.execute(
650        "UPDATE patterns SET status = ?2 WHERE id = ?1",
651        params![id, status.to_string()],
652    )?;
653    Ok(())
654}
655
656/// Set or clear the generation_failed flag on a pattern.
657pub fn set_generation_failed(
658    conn: &Connection,
659    id: &str,
660    failed: bool,
661) -> Result<(), CoreError> {
662    conn.execute(
663        "UPDATE patterns SET generation_failed = ?2 WHERE id = ?1",
664        params![id, failed as i32],
665    )?;
666    Ok(())
667}
668
669/// Get all projections for active patterns (for staleness detection).
670pub fn get_projections_for_active_patterns(
671    conn: &Connection,
672) -> Result<Vec<Projection>, CoreError> {
673    let mut stmt = conn.prepare(
674        "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
675         FROM projections p
676         INNER JOIN patterns pat ON p.pattern_id = pat.id
677         WHERE pat.status = 'active'",
678    )?;
679
680    let projections = stmt
681        .query_map([], |row| {
682            let applied_at_str: String = row.get(5)?;
683            let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
684                .map(|d| d.with_timezone(&Utc))
685                .unwrap_or_else(|_| Utc::now());
686            let status_str: String = row.get(7)?;
687            let status = ProjectionStatus::from_str(&status_str)
688                .unwrap_or(ProjectionStatus::Applied);
689            Ok(Projection {
690                id: row.get(0)?,
691                pattern_id: row.get(1)?,
692                target_type: row.get(2)?,
693                target_path: row.get(3)?,
694                content: row.get(4)?,
695                applied_at,
696                pr_url: row.get(6)?,
697                status,
698            })
699        })?
700        .filter_map(|r| r.ok())
701        .collect();
702
703    Ok(projections)
704}
705
706/// Update a pattern's last_projected timestamp to now.
707pub fn update_pattern_last_projected(conn: &Connection, id: &str) -> Result<(), CoreError> {
708    conn.execute(
709        "UPDATE patterns SET last_projected = ?2 WHERE id = ?1",
710        params![id, Utc::now().to_rfc3339()],
711    )?;
712    Ok(())
713}
714
715/// Get all projections with pending_review status.
716pub fn get_pending_review_projections(conn: &Connection) -> Result<Vec<Projection>, CoreError> {
717    let mut stmt = conn.prepare(
718        "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
719         FROM projections p
720         WHERE p.status = 'pending_review'
721         ORDER BY p.applied_at ASC",
722    )?;
723
724    let projections = stmt
725        .query_map([], |row| {
726            let applied_at_str: String = row.get(5)?;
727            let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
728                .map(|d| d.with_timezone(&Utc))
729                .unwrap_or_else(|_| Utc::now());
730            let status_str: String = row.get(7)?;
731            let status = ProjectionStatus::from_str(&status_str)
732                .unwrap_or(ProjectionStatus::PendingReview);
733            Ok(Projection {
734                id: row.get(0)?,
735                pattern_id: row.get(1)?,
736                target_type: row.get(2)?,
737                target_path: row.get(3)?,
738                content: row.get(4)?,
739                applied_at,
740                pr_url: row.get(6)?,
741                status,
742            })
743        })?
744        .filter_map(|r| r.ok())
745        .collect();
746
747    Ok(projections)
748}
749
750/// Update a projection's status.
751pub fn update_projection_status(
752    conn: &Connection,
753    projection_id: &str,
754    status: &ProjectionStatus,
755) -> Result<(), CoreError> {
756    conn.execute(
757        "UPDATE projections SET status = ?2 WHERE id = ?1",
758        params![projection_id, status.to_string()],
759    )?;
760    Ok(())
761}
762
763/// Delete a projection record.
764pub fn delete_projection(conn: &Connection, projection_id: &str) -> Result<(), CoreError> {
765    conn.execute("DELETE FROM projections WHERE id = ?1", params![projection_id])?;
766    Ok(())
767}
768
769/// Get applied projections that have a PR URL (for sync).
770pub fn get_applied_projections_with_pr(conn: &Connection) -> Result<Vec<Projection>, CoreError> {
771    let mut stmt = conn.prepare(
772        "SELECT p.id, p.pattern_id, p.target_type, p.target_path, p.content, p.applied_at, p.pr_url, p.status
773         FROM projections p
774         WHERE p.status = 'applied' AND p.pr_url IS NOT NULL",
775    )?;
776
777    let projections = stmt
778        .query_map([], |row| {
779            let applied_at_str: String = row.get(5)?;
780            let applied_at = DateTime::parse_from_rfc3339(&applied_at_str)
781                .map(|d| d.with_timezone(&Utc))
782                .unwrap_or_else(|_| Utc::now());
783            let status_str: String = row.get(7)?;
784            let status = ProjectionStatus::from_str(&status_str)
785                .unwrap_or(ProjectionStatus::Applied);
786            Ok(Projection {
787                id: row.get(0)?,
788                pattern_id: row.get(1)?,
789                target_type: row.get(2)?,
790                target_path: row.get(3)?,
791                content: row.get(4)?,
792                applied_at,
793                pr_url: row.get(6)?,
794                status,
795            })
796        })?
797        .filter_map(|r| r.ok())
798        .collect();
799
800    Ok(projections)
801}
802
803/// Get pattern IDs that have projections with specific statuses.
804pub fn get_projected_pattern_ids_by_status(
805    conn: &Connection,
806    statuses: &[ProjectionStatus],
807) -> Result<std::collections::HashSet<String>, CoreError> {
808    if statuses.is_empty() {
809        return Ok(std::collections::HashSet::new());
810    }
811    let placeholders: Vec<String> = statuses.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
812    let sql = format!(
813        "SELECT DISTINCT pattern_id FROM projections WHERE status IN ({})",
814        placeholders.join(", ")
815    );
816    let mut stmt = conn.prepare(&sql)?;
817    let params: Vec<String> = statuses.iter().map(|s| s.to_string()).collect();
818    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
819    let ids = stmt
820        .query_map(param_refs.as_slice(), |row| row.get(0))?
821        .filter_map(|r| r.ok())
822        .collect();
823    Ok(ids)
824}
825
826/// Update a projection's PR URL.
827pub fn update_projection_pr_url(
828    conn: &Connection,
829    projection_id: &str,
830    pr_url: &str,
831) -> Result<(), CoreError> {
832    conn.execute(
833        "UPDATE projections SET pr_url = ?2 WHERE id = ?1",
834        params![projection_id, pr_url],
835    )?;
836    Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::models::*;
843
844    fn test_db() -> Connection {
845        let conn = Connection::open_in_memory().unwrap();
846        conn.pragma_update(None, "journal_mode", "WAL").unwrap();
847        migrate(&conn).unwrap();
848        conn
849    }
850
851    fn test_pattern(id: &str, description: &str) -> Pattern {
852        Pattern {
853            id: id.to_string(),
854            pattern_type: PatternType::RepetitiveInstruction,
855            description: description.to_string(),
856            confidence: 0.85,
857            times_seen: 1,
858            first_seen: Utc::now(),
859            last_seen: Utc::now(),
860            last_projected: None,
861            status: PatternStatus::Discovered,
862            source_sessions: vec!["sess-1".to_string()],
863            related_files: vec![],
864            suggested_content: "Always do X".to_string(),
865            suggested_target: SuggestedTarget::ClaudeMd,
866            project: Some("/test/project".to_string()),
867            generation_failed: false,
868        }
869    }
870
871    #[test]
872    fn test_insert_and_get_pattern() {
873        let conn = test_db();
874        let pattern = test_pattern("pat-1", "Use uv for Python packages");
875        insert_pattern(&conn, &pattern).unwrap();
876
877        let patterns = get_all_patterns(&conn, None).unwrap();
878        assert_eq!(patterns.len(), 1);
879        assert_eq!(patterns[0].id, "pat-1");
880        assert_eq!(patterns[0].description, "Use uv for Python packages");
881        assert!((patterns[0].confidence - 0.85).abs() < f64::EPSILON);
882    }
883
884    #[test]
885    fn test_pattern_merge_update() {
886        let conn = test_db();
887        let pattern = test_pattern("pat-1", "Use uv for Python packages");
888        insert_pattern(&conn, &pattern).unwrap();
889
890        update_pattern_merge(
891            &conn,
892            "pat-1",
893            &["sess-2".to_string(), "sess-3".to_string()],
894            0.92,
895            Utc::now(),
896            2,
897        )
898        .unwrap();
899
900        let patterns = get_all_patterns(&conn, None).unwrap();
901        assert_eq!(patterns[0].times_seen, 3);
902        assert!((patterns[0].confidence - 0.92).abs() < f64::EPSILON);
903        assert_eq!(patterns[0].source_sessions.len(), 3);
904    }
905
906    #[test]
907    fn test_get_patterns_by_status() {
908        let conn = test_db();
909        let p1 = test_pattern("pat-1", "Pattern one");
910        let mut p2 = test_pattern("pat-2", "Pattern two");
911        p2.status = PatternStatus::Active;
912        insert_pattern(&conn, &p1).unwrap();
913        insert_pattern(&conn, &p2).unwrap();
914
915        let discovered = get_patterns(&conn, &["discovered"], None).unwrap();
916        assert_eq!(discovered.len(), 1);
917        assert_eq!(discovered[0].id, "pat-1");
918
919        let active = get_patterns(&conn, &["active"], None).unwrap();
920        assert_eq!(active.len(), 1);
921        assert_eq!(active[0].id, "pat-2");
922
923        let both = get_patterns(&conn, &["discovered", "active"], None).unwrap();
924        assert_eq!(both.len(), 2);
925    }
926
927    #[test]
928    fn test_analyzed_session_tracking() {
929        let conn = test_db();
930        assert!(!is_session_analyzed(&conn, "sess-1").unwrap());
931
932        record_analyzed_session(&conn, "sess-1", "/test").unwrap();
933        assert!(is_session_analyzed(&conn, "sess-1").unwrap());
934        assert!(!is_session_analyzed(&conn, "sess-2").unwrap());
935    }
936
937    #[test]
938    fn test_sessions_for_analysis() {
939        let conn = test_db();
940
941        // Record an ingested session
942        let session = IngestedSession {
943            session_id: "sess-1".to_string(),
944            project: "/test".to_string(),
945            session_path: "/tmp/test.jsonl".to_string(),
946            file_size: 100,
947            file_mtime: "2026-01-01T00:00:00Z".to_string(),
948            ingested_at: Utc::now(),
949        };
950        record_ingested_session(&conn, &session).unwrap();
951
952        // It should appear in sessions for analysis (non-rolling)
953        let since = Utc::now() - chrono::Duration::days(14);
954        let pending = get_sessions_for_analysis(&conn, None, &since, false).unwrap();
955        assert_eq!(pending.len(), 1);
956
957        // After marking as analyzed, it should not appear in non-rolling mode
958        record_analyzed_session(&conn, "sess-1", "/test").unwrap();
959        let pending = get_sessions_for_analysis(&conn, None, &since, false).unwrap();
960        assert_eq!(pending.len(), 0);
961
962        // But it SHOULD still appear in rolling window mode
963        let pending = get_sessions_for_analysis(&conn, None, &since, true).unwrap();
964        assert_eq!(pending.len(), 1);
965    }
966
967    #[test]
968    fn test_insert_and_check_projection() {
969        let conn = test_db();
970        let pattern = test_pattern("pat-1", "Use uv");
971        insert_pattern(&conn, &pattern).unwrap();
972
973        assert!(!has_projection_for_pattern(&conn, "pat-1").unwrap());
974
975        let proj = Projection {
976            id: "proj-1".to_string(),
977            pattern_id: "pat-1".to_string(),
978            target_type: "claude_md".to_string(),
979            target_path: "/test/CLAUDE.md".to_string(),
980            content: "Always use uv".to_string(),
981            applied_at: Utc::now(),
982            pr_url: None,
983            status: ProjectionStatus::Applied,
984        };
985        insert_projection(&conn, &proj).unwrap();
986
987        assert!(has_projection_for_pattern(&conn, "pat-1").unwrap());
988        assert!(!has_projection_for_pattern(&conn, "pat-2").unwrap());
989    }
990
991    #[test]
992    fn test_update_pattern_status() {
993        let conn = test_db();
994        let pattern = test_pattern("pat-1", "Test pattern");
995        insert_pattern(&conn, &pattern).unwrap();
996
997        update_pattern_status(&conn, "pat-1", &PatternStatus::Active).unwrap();
998        let patterns = get_patterns(&conn, &["active"], None).unwrap();
999        assert_eq!(patterns.len(), 1);
1000        assert_eq!(patterns[0].id, "pat-1");
1001    }
1002
1003    #[test]
1004    fn test_set_generation_failed() {
1005        let conn = test_db();
1006        let pattern = test_pattern("pat-1", "Test pattern");
1007        insert_pattern(&conn, &pattern).unwrap();
1008
1009        assert!(!get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1010
1011        set_generation_failed(&conn, "pat-1", true).unwrap();
1012        assert!(get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1013
1014        set_generation_failed(&conn, "pat-1", false).unwrap();
1015        assert!(!get_all_patterns(&conn, None).unwrap()[0].generation_failed);
1016    }
1017
1018    #[test]
1019    fn test_projections_nudged_column_defaults_to_zero() {
1020        let conn = test_db();
1021
1022        // Verify the nudged column exists by preparing a statement that references it
1023        conn.prepare("SELECT nudged FROM projections").unwrap();
1024
1025        // Insert a projection without specifying nudged — should default to 0
1026        let pattern = test_pattern("pat-1", "Test pattern");
1027        insert_pattern(&conn, &pattern).unwrap();
1028
1029        let proj = Projection {
1030            id: "proj-1".to_string(),
1031            pattern_id: "pat-1".to_string(),
1032            target_type: "claude_md".to_string(),
1033            target_path: "/test/CLAUDE.md".to_string(),
1034            content: "Always use uv".to_string(),
1035            applied_at: Utc::now(),
1036            pr_url: None,
1037            status: ProjectionStatus::Applied,
1038        };
1039        insert_projection(&conn, &proj).unwrap();
1040
1041        let nudged: i64 = conn
1042            .query_row(
1043                "SELECT nudged FROM projections WHERE id = 'proj-1'",
1044                [],
1045                |row| row.get(0),
1046            )
1047            .unwrap();
1048        assert_eq!(nudged, 0, "nudged column should default to 0");
1049    }
1050
1051    // ── Tests for auto-apply pipeline DB functions ──
1052
1053    #[test]
1054    fn test_last_applied_at_empty() {
1055        let conn = test_db();
1056        let result = last_applied_at(&conn).unwrap();
1057        assert_eq!(result, None);
1058    }
1059
1060    #[test]
1061    fn test_last_applied_at_returns_max() {
1062        let conn = test_db();
1063
1064        // Insert two patterns to serve as FK targets
1065        let p1 = test_pattern("pat-1", "Pattern one");
1066        let p2 = test_pattern("pat-2", "Pattern two");
1067        insert_pattern(&conn, &p1).unwrap();
1068        insert_pattern(&conn, &p2).unwrap();
1069
1070        // Insert projections with different timestamps
1071        let earlier = chrono::DateTime::parse_from_rfc3339("2026-01-10T00:00:00Z")
1072            .unwrap()
1073            .with_timezone(&Utc);
1074        let later = chrono::DateTime::parse_from_rfc3339("2026-02-15T12:00:00Z")
1075            .unwrap()
1076            .with_timezone(&Utc);
1077
1078        let proj1 = Projection {
1079            id: "proj-1".to_string(),
1080            pattern_id: "pat-1".to_string(),
1081            target_type: "Skill".to_string(),
1082            target_path: "/path/a".to_string(),
1083            content: "content a".to_string(),
1084            applied_at: earlier,
1085            pr_url: None,
1086            status: ProjectionStatus::Applied,
1087        };
1088        let proj2 = Projection {
1089            id: "proj-2".to_string(),
1090            pattern_id: "pat-2".to_string(),
1091            target_type: "Skill".to_string(),
1092            target_path: "/path/b".to_string(),
1093            content: "content b".to_string(),
1094            applied_at: later,
1095            pr_url: None,
1096            status: ProjectionStatus::Applied,
1097        };
1098        insert_projection(&conn, &proj1).unwrap();
1099        insert_projection(&conn, &proj2).unwrap();
1100
1101        let result = last_applied_at(&conn).unwrap();
1102        assert!(result.is_some());
1103        // The max should be the later timestamp
1104        let max_ts = result.unwrap();
1105        assert!(max_ts.contains("2026-02-15"), "Expected later timestamp, got: {}", max_ts);
1106    }
1107
1108    #[test]
1109    fn test_has_unanalyzed_sessions_empty() {
1110        let conn = test_db();
1111        assert!(!has_unanalyzed_sessions(&conn).unwrap());
1112    }
1113
1114    #[test]
1115    fn test_has_unanalyzed_sessions_with_new_session() {
1116        let conn = test_db();
1117
1118        let session = IngestedSession {
1119            session_id: "sess-1".to_string(),
1120            project: "/test".to_string(),
1121            session_path: "/tmp/test.jsonl".to_string(),
1122            file_size: 100,
1123            file_mtime: "2026-01-01T00:00:00Z".to_string(),
1124            ingested_at: Utc::now(),
1125        };
1126        record_ingested_session(&conn, &session).unwrap();
1127
1128        assert!(has_unanalyzed_sessions(&conn).unwrap());
1129    }
1130
1131    #[test]
1132    fn test_has_unanalyzed_sessions_after_analysis() {
1133        let conn = test_db();
1134
1135        let session = IngestedSession {
1136            session_id: "sess-1".to_string(),
1137            project: "/test".to_string(),
1138            session_path: "/tmp/test.jsonl".to_string(),
1139            file_size: 100,
1140            file_mtime: "2026-01-01T00:00:00Z".to_string(),
1141            ingested_at: Utc::now(),
1142        };
1143        record_ingested_session(&conn, &session).unwrap();
1144        record_analyzed_session(&conn, "sess-1", "/test").unwrap();
1145
1146        assert!(!has_unanalyzed_sessions(&conn).unwrap());
1147    }
1148
1149    #[test]
1150    fn test_has_unprojected_patterns_empty() {
1151        let conn = test_db();
1152        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1153    }
1154
1155    #[test]
1156    fn test_has_unprojected_patterns_with_discovered() {
1157        let conn = test_db();
1158
1159        let pattern = test_pattern("pat-1", "Use uv for Python");
1160        insert_pattern(&conn, &pattern).unwrap();
1161
1162        assert!(has_unprojected_patterns(&conn, 0.0).unwrap());
1163    }
1164
1165    #[test]
1166    fn test_has_unprojected_patterns_after_projection() {
1167        let conn = test_db();
1168
1169        let pattern = test_pattern("pat-1", "Use uv for Python");
1170        insert_pattern(&conn, &pattern).unwrap();
1171
1172        let proj = Projection {
1173            id: "proj-1".to_string(),
1174            pattern_id: "pat-1".to_string(),
1175            target_type: "Skill".to_string(),
1176            target_path: "/path".to_string(),
1177            content: "content".to_string(),
1178            applied_at: Utc::now(),
1179            pr_url: Some("https://github.com/test/pull/1".to_string()),
1180            status: ProjectionStatus::Applied,
1181        };
1182        insert_projection(&conn, &proj).unwrap();
1183
1184        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1185    }
1186
1187    #[test]
1188    fn test_has_unprojected_patterns_excludes_generation_failed() {
1189        let conn = test_db();
1190
1191        let pattern = test_pattern("pat-1", "Use uv for Python");
1192        insert_pattern(&conn, &pattern).unwrap();
1193        set_generation_failed(&conn, "pat-1", true).unwrap();
1194
1195        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1196    }
1197
1198    #[test]
1199    fn test_has_unprojected_patterns_excludes_dbonly() {
1200        let conn = test_db();
1201
1202        let mut pattern = test_pattern("pat-1", "Internal tracking only");
1203        pattern.suggested_target = SuggestedTarget::DbOnly;
1204        insert_pattern(&conn, &pattern).unwrap();
1205
1206        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1207    }
1208
1209    #[test]
1210    fn test_auto_apply_data_triggers_full_flow() {
1211        let conn = test_db();
1212
1213        // Initially: no data, no triggers
1214        assert!(!has_unanalyzed_sessions(&conn).unwrap());
1215        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1216
1217        // Step 1: Ingest creates sessions → triggers analyze
1218        let session = IngestedSession {
1219            session_id: "sess-1".to_string(),
1220            project: "/proj".to_string(),
1221            session_path: "/path/sess".to_string(),
1222            file_size: 100,
1223            file_mtime: "2025-01-01T00:00:00Z".to_string(),
1224            ingested_at: Utc::now(),
1225        };
1226        record_ingested_session(&conn, &session).unwrap();
1227        assert!(has_unanalyzed_sessions(&conn).unwrap());
1228
1229        // Step 2: After analysis → sessions marked, patterns created → triggers apply
1230        record_analyzed_session(&conn, "sess-1", "/proj").unwrap();
1231        assert!(!has_unanalyzed_sessions(&conn).unwrap());
1232
1233        let p = test_pattern("pat-1", "Always use cargo fmt");
1234        insert_pattern(&conn, &p).unwrap();
1235        assert!(has_unprojected_patterns(&conn, 0.0).unwrap());
1236
1237        // Step 3: After apply → projection created with PR URL
1238        let proj = Projection {
1239            id: "proj-1".to_string(),
1240            pattern_id: "pat-1".to_string(),
1241            target_type: "Skill".to_string(),
1242            target_path: "/skills/cargo-fmt.md".to_string(),
1243            content: "skill content".to_string(),
1244            applied_at: Utc::now(),
1245            pr_url: Some("https://github.com/test/pull/42".to_string()),
1246            status: ProjectionStatus::Applied,
1247        };
1248        insert_projection(&conn, &proj).unwrap();
1249        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1250    }
1251
1252    #[test]
1253    fn test_get_last_nudge_at_empty() {
1254        let conn = test_db();
1255        assert!(get_last_nudge_at(&conn).unwrap().is_none());
1256    }
1257
1258    #[test]
1259    fn test_unanalyzed_session_count() {
1260        let conn = test_db();
1261        assert_eq!(unanalyzed_session_count(&conn).unwrap(), 0);
1262
1263        // Add 3 sessions
1264        for i in 1..=3 {
1265            let session = IngestedSession {
1266                session_id: format!("sess-{i}"),
1267                project: "/proj".to_string(),
1268                session_path: format!("/path/sess-{i}"),
1269                file_size: 100,
1270                file_mtime: "2025-01-01T00:00:00Z".to_string(),
1271                ingested_at: Utc::now(),
1272            };
1273            record_ingested_session(&conn, &session).unwrap();
1274        }
1275        assert_eq!(unanalyzed_session_count(&conn).unwrap(), 3);
1276
1277        // Analyze one
1278        record_analyzed_session(&conn, "sess-1", "/proj").unwrap();
1279        assert_eq!(unanalyzed_session_count(&conn).unwrap(), 2);
1280    }
1281
1282    #[test]
1283    fn test_set_and_get_last_nudge_at() {
1284        let conn = test_db();
1285        let now = Utc::now();
1286        set_last_nudge_at(&conn, &now).unwrap();
1287        let result = get_last_nudge_at(&conn).unwrap().unwrap();
1288        // Compare to second precision (DB stores RFC 3339)
1289        assert_eq!(
1290            result.format("%Y-%m-%dT%H:%M:%S").to_string(),
1291            now.format("%Y-%m-%dT%H:%M:%S").to_string()
1292        );
1293    }
1294
1295    #[test]
1296    fn test_projection_status_column_exists() {
1297        let conn = test_db();
1298        let pattern = test_pattern("pat-1", "Test");
1299        insert_pattern(&conn, &pattern).unwrap();
1300
1301        let proj = Projection {
1302            id: "proj-1".to_string(),
1303            pattern_id: "pat-1".to_string(),
1304            target_type: "skill".to_string(),
1305            target_path: "/test/skill.md".to_string(),
1306            content: "content".to_string(),
1307            applied_at: Utc::now(),
1308            pr_url: None,
1309            status: ProjectionStatus::PendingReview,
1310        };
1311        insert_projection(&conn, &proj).unwrap();
1312
1313        let status: String = conn
1314            .query_row(
1315                "SELECT status FROM projections WHERE id = 'proj-1'",
1316                [],
1317                |row| row.get(0),
1318            )
1319            .unwrap();
1320        assert_eq!(status, "pending_review");
1321    }
1322
1323    #[test]
1324    fn test_existing_projections_default_to_applied() {
1325        // Simulate a v2 database with existing projections
1326        let conn = Connection::open_in_memory().unwrap();
1327        conn.pragma_update(None, "journal_mode", "WAL").unwrap();
1328
1329        // Create v1 schema manually
1330        conn.execute_batch(
1331            "CREATE TABLE patterns (
1332                id TEXT PRIMARY KEY, pattern_type TEXT NOT NULL, description TEXT NOT NULL,
1333                confidence REAL NOT NULL, times_seen INTEGER NOT NULL DEFAULT 1,
1334                first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, last_projected TEXT,
1335                status TEXT NOT NULL DEFAULT 'discovered', source_sessions TEXT NOT NULL,
1336                related_files TEXT NOT NULL, suggested_content TEXT NOT NULL,
1337                suggested_target TEXT NOT NULL, project TEXT,
1338                generation_failed INTEGER NOT NULL DEFAULT 0
1339            );
1340            CREATE TABLE projections (
1341                id TEXT PRIMARY KEY, pattern_id TEXT NOT NULL REFERENCES patterns(id),
1342                target_type TEXT NOT NULL, target_path TEXT NOT NULL, content TEXT NOT NULL,
1343                applied_at TEXT NOT NULL, pr_url TEXT, nudged INTEGER NOT NULL DEFAULT 0
1344            );
1345            CREATE TABLE analyzed_sessions (session_id TEXT PRIMARY KEY, project TEXT NOT NULL, analyzed_at TEXT NOT NULL);
1346            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);
1347            PRAGMA user_version = 1;",
1348        ).unwrap();
1349
1350        // Insert a pattern first (FK target)
1351        conn.execute(
1352            "INSERT INTO patterns (id, pattern_type, description, confidence, first_seen, last_seen, status, source_sessions, related_files, suggested_content, suggested_target)
1353             VALUES ('pat-1', 'workflow_pattern', 'Test', 0.8, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'discovered', '[]', '[]', 'content', 'skill')",
1354            [],
1355        ).unwrap();
1356
1357        // Insert an old-style projection (no status column)
1358        conn.execute(
1359            "INSERT INTO projections (id, pattern_id, target_type, target_path, content, applied_at)
1360             VALUES ('proj-old', 'pat-1', 'skill', '/path', 'content', '2026-01-01T00:00:00Z')",
1361            [],
1362        ).unwrap();
1363
1364        // Now run migration (open_db equivalent)
1365        migrate(&conn).unwrap();
1366
1367        // Old projection should have status = 'applied'
1368        let status: String = conn
1369            .query_row("SELECT status FROM projections WHERE id = 'proj-old'", [], |row| row.get(0))
1370            .unwrap();
1371        assert_eq!(status, "applied");
1372    }
1373
1374    #[test]
1375    fn test_get_pending_review_projections() {
1376        let conn = test_db();
1377        let p1 = test_pattern("pat-1", "Pattern one");
1378        let p2 = test_pattern("pat-2", "Pattern two");
1379        insert_pattern(&conn, &p1).unwrap();
1380        insert_pattern(&conn, &p2).unwrap();
1381
1382        // One pending, one applied
1383        let proj1 = Projection {
1384            id: "proj-1".to_string(),
1385            pattern_id: "pat-1".to_string(),
1386            target_type: "skill".to_string(),
1387            target_path: "/test/a.md".to_string(),
1388            content: "content a".to_string(),
1389            applied_at: Utc::now(),
1390            pr_url: None,
1391            status: ProjectionStatus::PendingReview,
1392        };
1393        let proj2 = Projection {
1394            id: "proj-2".to_string(),
1395            pattern_id: "pat-2".to_string(),
1396            target_type: "skill".to_string(),
1397            target_path: "/test/b.md".to_string(),
1398            content: "content b".to_string(),
1399            applied_at: Utc::now(),
1400            pr_url: None,
1401            status: ProjectionStatus::Applied,
1402        };
1403        insert_projection(&conn, &proj1).unwrap();
1404        insert_projection(&conn, &proj2).unwrap();
1405
1406        let pending = get_pending_review_projections(&conn).unwrap();
1407        assert_eq!(pending.len(), 1);
1408        assert_eq!(pending[0].id, "proj-1");
1409    }
1410
1411    #[test]
1412    fn test_update_projection_status() {
1413        let conn = test_db();
1414        let p = test_pattern("pat-1", "Pattern");
1415        insert_pattern(&conn, &p).unwrap();
1416
1417        let proj = Projection {
1418            id: "proj-1".to_string(),
1419            pattern_id: "pat-1".to_string(),
1420            target_type: "skill".to_string(),
1421            target_path: "/test.md".to_string(),
1422            content: "content".to_string(),
1423            applied_at: Utc::now(),
1424            pr_url: None,
1425            status: ProjectionStatus::PendingReview,
1426        };
1427        insert_projection(&conn, &proj).unwrap();
1428
1429        update_projection_status(&conn, "proj-1", &ProjectionStatus::Applied).unwrap();
1430
1431        let status: String = conn
1432            .query_row("SELECT status FROM projections WHERE id = 'proj-1'", [], |row| row.get(0))
1433            .unwrap();
1434        assert_eq!(status, "applied");
1435    }
1436
1437    #[test]
1438    fn test_delete_projection() {
1439        let conn = test_db();
1440        let p = test_pattern("pat-1", "Pattern");
1441        insert_pattern(&conn, &p).unwrap();
1442
1443        let proj = Projection {
1444            id: "proj-1".to_string(),
1445            pattern_id: "pat-1".to_string(),
1446            target_type: "skill".to_string(),
1447            target_path: "/test.md".to_string(),
1448            content: "content".to_string(),
1449            applied_at: Utc::now(),
1450            pr_url: None,
1451            status: ProjectionStatus::PendingReview,
1452        };
1453        insert_projection(&conn, &proj).unwrap();
1454        assert!(has_projection_for_pattern(&conn, "pat-1").unwrap());
1455
1456        delete_projection(&conn, "proj-1").unwrap();
1457        assert!(!has_projection_for_pattern(&conn, "pat-1").unwrap());
1458    }
1459
1460    #[test]
1461    fn test_get_projections_with_pr_url() {
1462        let conn = test_db();
1463        let p1 = test_pattern("pat-1", "Pattern one");
1464        let p2 = test_pattern("pat-2", "Pattern two");
1465        insert_pattern(&conn, &p1).unwrap();
1466        insert_pattern(&conn, &p2).unwrap();
1467
1468        let proj1 = Projection {
1469            id: "proj-1".to_string(),
1470            pattern_id: "pat-1".to_string(),
1471            target_type: "skill".to_string(),
1472            target_path: "/a.md".to_string(),
1473            content: "a".to_string(),
1474            applied_at: Utc::now(),
1475            pr_url: Some("https://github.com/test/pull/1".to_string()),
1476            status: ProjectionStatus::Applied,
1477        };
1478        let proj2 = Projection {
1479            id: "proj-2".to_string(),
1480            pattern_id: "pat-2".to_string(),
1481            target_type: "skill".to_string(),
1482            target_path: "/b.md".to_string(),
1483            content: "b".to_string(),
1484            applied_at: Utc::now(),
1485            pr_url: None,
1486            status: ProjectionStatus::Applied,
1487        };
1488        insert_projection(&conn, &proj1).unwrap();
1489        insert_projection(&conn, &proj2).unwrap();
1490
1491        let with_pr = get_applied_projections_with_pr(&conn).unwrap();
1492        assert_eq!(with_pr.len(), 1);
1493        assert_eq!(with_pr[0].pr_url, Some("https://github.com/test/pull/1".to_string()));
1494    }
1495
1496    #[test]
1497    fn test_get_projected_pattern_ids_by_status() {
1498        let conn = test_db();
1499        let p1 = test_pattern("pat-1", "Pattern one");
1500        let p2 = test_pattern("pat-2", "Pattern two");
1501        insert_pattern(&conn, &p1).unwrap();
1502        insert_pattern(&conn, &p2).unwrap();
1503
1504        let proj1 = Projection {
1505            id: "proj-1".to_string(),
1506            pattern_id: "pat-1".to_string(),
1507            target_type: "skill".to_string(),
1508            target_path: "/a.md".to_string(),
1509            content: "a".to_string(),
1510            applied_at: Utc::now(),
1511            pr_url: None,
1512            status: ProjectionStatus::Applied,
1513        };
1514        let proj2 = Projection {
1515            id: "proj-2".to_string(),
1516            pattern_id: "pat-2".to_string(),
1517            target_type: "skill".to_string(),
1518            target_path: "/b.md".to_string(),
1519            content: "b".to_string(),
1520            applied_at: Utc::now(),
1521            pr_url: None,
1522            status: ProjectionStatus::PendingReview,
1523        };
1524        insert_projection(&conn, &proj1).unwrap();
1525        insert_projection(&conn, &proj2).unwrap();
1526
1527        let ids = get_projected_pattern_ids_by_status(&conn, &[ProjectionStatus::Applied, ProjectionStatus::PendingReview]).unwrap();
1528        assert_eq!(ids.len(), 2);
1529
1530        let ids_applied_only = get_projected_pattern_ids_by_status(&conn, &[ProjectionStatus::Applied]).unwrap();
1531        assert_eq!(ids_applied_only.len(), 1);
1532        assert!(ids_applied_only.contains("pat-1"));
1533    }
1534
1535    #[test]
1536    fn test_has_unprojected_patterns_excludes_dismissed() {
1537        let conn = test_db();
1538
1539        let mut pattern = test_pattern("pat-1", "Dismissed pattern");
1540        pattern.status = PatternStatus::Dismissed;
1541        insert_pattern(&conn, &pattern).unwrap();
1542
1543        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1544    }
1545
1546    #[test]
1547    fn test_has_unprojected_patterns_excludes_pending_review() {
1548        let conn = test_db();
1549
1550        let pattern = test_pattern("pat-1", "Pattern with pending review");
1551        insert_pattern(&conn, &pattern).unwrap();
1552
1553        // Create a pending_review projection
1554        let proj = Projection {
1555            id: "proj-1".to_string(),
1556            pattern_id: "pat-1".to_string(),
1557            target_type: "skill".to_string(),
1558            target_path: "/test.md".to_string(),
1559            content: "content".to_string(),
1560            applied_at: Utc::now(),
1561            pr_url: None,
1562            status: ProjectionStatus::PendingReview,
1563        };
1564        insert_projection(&conn, &proj).unwrap();
1565
1566        // Pattern already has a pending_review projection — should NOT be "unprojected"
1567        assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1568    }
1569}