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
11pub fn open_db(path: &Path) -> Result<Connection, CoreError> {
13 let conn = Connection::open(path)?;
14
15 conn.pragma_update(None, "journal_mode", "WAL")?;
17
18 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
107pub 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
131pub 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
151pub 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
160pub 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
173pub 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
182pub 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
192pub 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
202pub 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
212pub 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
222pub 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
234pub 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
246pub 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
265pub 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
284pub 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
293pub 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
299pub 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
310const 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
314pub 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
345pub 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 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
388pub 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
438pub 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
501pub 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
517pub 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
527pub 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
600pub 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
621pub 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
631pub 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
643pub 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
656pub 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
669pub 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
706pub 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
715pub 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
750pub 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
763pub 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
769pub 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
803pub 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
826pub 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 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 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 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 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 conn.prepare("SELECT nudged FROM projections").unwrap();
1024
1025 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 #[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 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 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 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 assert!(!has_unanalyzed_sessions(&conn).unwrap());
1215 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1216
1217 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 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 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 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 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 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 let conn = Connection::open_in_memory().unwrap();
1327 conn.pragma_update(None, "journal_mode", "WAL").unwrap();
1328
1329 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 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 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 migrate(&conn).unwrap();
1366
1367 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 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 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 assert!(!has_unprojected_patterns(&conn, 0.0).unwrap());
1568 }
1569}