1use rusqlite::{Connection, OptionalExtension, params};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum QueryError {
16 #[error("SQLite error: {0}")]
18 Sqlite(#[from] rusqlite::Error),
19
20 #[error("Invalid parameter: {0}")]
22 InvalidParameter(String),
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TimelineEvent {
28 pub event_type: String,
30 pub event_id: String,
32 pub workspace_id: String,
34 pub event_timestamp: String,
36 pub summary: String,
38 pub details_json: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SearchResult {
45 pub result_type: String,
47 pub id: String,
49 pub snippet: String,
51 pub rank: f64,
53 pub timestamp: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FailingTest {
60 pub test_name: String,
62 pub suite_name: String,
64 pub full_name: String,
66 pub duration_ms: Option<i64>,
68 pub output_json: Option<String>,
70 pub run_id: String,
72 pub commit_sha: Option<String>,
74 pub started_at: String,
76}
77
78fn resolve_workspace_filter(conn: &Connection, filter: &str) -> Result<Option<String>, QueryError> {
93 let result: Result<String, _> = conn.query_row(
95 "SELECT id FROM workspaces WHERE path = ?",
96 [filter],
97 |row| row.get(0),
98 );
99
100 match result {
101 Ok(id) => Ok(Some(id)),
102 Err(rusqlite::Error::QueryReturnedNoRows) => {
103 let exists: Result<i64, _> =
105 conn.query_row("SELECT 1 FROM workspaces WHERE id = ?", [filter], |row| {
106 row.get(0)
107 });
108 match exists {
109 Ok(_) => Ok(Some(filter.to_string())),
110 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
111 Err(e) => Err(QueryError::Sqlite(e)),
112 }
113 }
114 Err(e) => Err(QueryError::Sqlite(e)),
115 }
116}
117
118pub fn get_timeline(
130 conn: &Connection,
131 limit: usize,
132 workspace_filter: Option<&str>,
133) -> Result<Vec<TimelineEvent>, QueryError> {
134 let mut events = Vec::new();
135
136 let resolved_workspace_id = match workspace_filter {
138 Some(filter) => resolve_workspace_filter(conn, filter)?,
139 None => None,
140 };
141
142 if let Some(workspace_id) = resolved_workspace_id {
143 let mut stmt = conn.prepare(
144 r#"
145 SELECT event_type, event_id, workspace_id, event_timestamp, summary, details_json
146 FROM timeline
147 WHERE workspace_id = ?
148 ORDER BY event_timestamp DESC
149 LIMIT ?
150 "#,
151 )?;
152
153 let rows = stmt.query_map(params![workspace_id, limit as i64], |row| {
154 Ok(TimelineEvent {
155 event_type: row.get(0)?,
156 event_id: row.get(1)?,
157 workspace_id: row.get(2)?,
158 event_timestamp: row.get(3)?,
159 summary: row.get(4)?,
160 details_json: row.get(5)?,
161 })
162 })?;
163
164 for row in rows {
165 events.push(row?);
166 }
167 } else {
168 let mut stmt = conn.prepare(
169 r#"
170 SELECT event_type, event_id, workspace_id, event_timestamp, summary, details_json
171 FROM timeline
172 ORDER BY event_timestamp DESC
173 LIMIT ?
174 "#,
175 )?;
176
177 let rows = stmt.query_map([limit as i64], |row| {
178 Ok(TimelineEvent {
179 event_type: row.get(0)?,
180 event_id: row.get(1)?,
181 workspace_id: row.get(2)?,
182 event_timestamp: row.get(3)?,
183 summary: row.get(4)?,
184 details_json: row.get(5)?,
185 })
186 })?;
187
188 for row in rows {
189 events.push(row?);
190 }
191 }
192
193 Ok(events)
194}
195
196pub fn search_commits(
208 conn: &Connection,
209 query: &str,
210 limit: usize,
211) -> Result<Vec<SearchResult>, QueryError> {
212 if query.is_empty() {
213 return Err(QueryError::InvalidParameter("Query cannot be empty".into()));
214 }
215
216 let mut results = Vec::new();
217
218 let mut stmt = conn.prepare(
220 r#"
221 SELECT
222 c.id,
223 snippet(commits_fts, 0, '<mark>', '</mark>', '...', 32) AS snippet,
224 commits_fts.rank,
225 c.timestamp
226 FROM commits_fts
227 JOIN commits c ON c.rowid = commits_fts.rowid
228 WHERE commits_fts MATCH ?
229 ORDER BY commits_fts.rank
230 LIMIT ?
231 "#,
232 )?;
233
234 let rows = stmt.query_map(params![query, limit as i64], |row| {
235 Ok(SearchResult {
236 result_type: "commit".to_string(),
237 id: row.get(0)?,
238 snippet: row.get(1)?,
239 rank: row.get(2)?,
240 timestamp: row.get(3)?,
241 })
242 })?;
243
244 for row in rows {
245 results.push(row?);
246 }
247
248 Ok(results)
249}
250
251pub fn search_messages(
263 conn: &Connection,
264 query: &str,
265 limit: usize,
266) -> Result<Vec<SearchResult>, QueryError> {
267 if query.is_empty() {
268 return Err(QueryError::InvalidParameter("Query cannot be empty".into()));
269 }
270
271 let mut results = Vec::new();
272
273 let mut stmt = conn.prepare(
275 r#"
276 SELECT
277 m.id,
278 snippet(copilot_messages_fts, 0, '<mark>', '</mark>', '...', 32) AS snippet,
279 copilot_messages_fts.rank,
280 m.timestamp
281 FROM copilot_messages_fts
282 JOIN copilot_messages m ON m.rowid = copilot_messages_fts.rowid
283 WHERE copilot_messages_fts MATCH ?
284 ORDER BY copilot_messages_fts.rank
285 LIMIT ?
286 "#,
287 )?;
288
289 let rows = stmt.query_map(params![query, limit as i64], |row| {
290 Ok(SearchResult {
291 result_type: "copilot_message".to_string(),
292 id: row.get(0)?,
293 snippet: row.get(1)?,
294 rank: row.get(2)?,
295 timestamp: row.get(3)?,
296 })
297 })?;
298
299 for row in rows {
300 results.push(row?);
301 }
302
303 Ok(results)
304}
305
306pub fn search_all(
318 conn: &Connection,
319 query: &str,
320 limit: usize,
321) -> Result<Vec<SearchResult>, QueryError> {
322 let mut results = Vec::new();
323
324 results.extend(search_commits(conn, query, limit)?);
326
327 results.extend(search_messages(conn, query, limit)?);
329
330 results.sort_by(|a, b| {
332 a.rank
333 .partial_cmp(&b.rank)
334 .unwrap_or(std::cmp::Ordering::Equal)
335 });
336
337 results.truncate(limit);
339
340 Ok(results)
341}
342
343pub fn get_failing_tests(
356 conn: &Connection,
357 limit: usize,
358 workspace_filter: Option<&str>,
359 commit_filter: Option<&str>,
360) -> Result<Vec<FailingTest>, QueryError> {
361 let mut tests = Vec::new();
362
363 let resolved_workspace_id = match workspace_filter {
365 Some(filter) => resolve_workspace_filter(conn, filter)?,
366 None => None,
367 };
368
369 match (resolved_workspace_id, commit_filter) {
374 (Some(workspace_id), Some(commit)) => {
375 let commit_pattern = format!("{}%", commit);
377 let mut stmt = conn.prepare(
378 r#"
379 SELECT ft.test_name, ft.suite_name, ft.full_name, ft.duration_ms,
380 ft.output_json, ft.run_id, ft.commit_sha, ft.started_at
381 FROM failing_tests ft
382 JOIN test_runs tr ON tr.id = ft.run_id
383 WHERE tr.workspace_id = ? AND ft.commit_sha LIKE ?
384 ORDER BY ft.started_at DESC
385 LIMIT ?
386 "#,
387 )?;
388
389 let rows =
390 stmt.query_map(params![workspace_id, commit_pattern, limit as i64], |row| {
391 Ok(FailingTest {
392 test_name: row.get(0)?,
393 suite_name: row.get(1)?,
394 full_name: row.get(2)?,
395 duration_ms: row.get(3)?,
396 output_json: row.get(4)?,
397 run_id: row.get(5)?,
398 commit_sha: row.get(6)?,
399 started_at: row.get(7)?,
400 })
401 })?;
402
403 for row in rows {
404 tests.push(row?);
405 }
406 }
407 (Some(workspace_id), None) => {
408 let mut stmt = conn.prepare(
410 r#"
411 SELECT ft.test_name, ft.suite_name, ft.full_name, ft.duration_ms,
412 ft.output_json, ft.run_id, ft.commit_sha, ft.started_at
413 FROM failing_tests ft
414 JOIN test_runs tr ON tr.id = ft.run_id
415 WHERE tr.workspace_id = ?
416 ORDER BY ft.started_at DESC
417 LIMIT ?
418 "#,
419 )?;
420
421 let rows = stmt.query_map(params![workspace_id, limit as i64], |row| {
422 Ok(FailingTest {
423 test_name: row.get(0)?,
424 suite_name: row.get(1)?,
425 full_name: row.get(2)?,
426 duration_ms: row.get(3)?,
427 output_json: row.get(4)?,
428 run_id: row.get(5)?,
429 commit_sha: row.get(6)?,
430 started_at: row.get(7)?,
431 })
432 })?;
433
434 for row in rows {
435 tests.push(row?);
436 }
437 }
438 (None, Some(commit)) => {
439 let commit_pattern = format!("{}%", commit);
441 let mut stmt = conn.prepare(
442 r#"
443 SELECT test_name, suite_name, full_name, duration_ms,
444 output_json, run_id, commit_sha, started_at
445 FROM failing_tests
446 WHERE commit_sha LIKE ?
447 ORDER BY started_at DESC
448 LIMIT ?
449 "#,
450 )?;
451
452 let rows = stmt.query_map(params![commit_pattern, limit as i64], |row| {
453 Ok(FailingTest {
454 test_name: row.get(0)?,
455 suite_name: row.get(1)?,
456 full_name: row.get(2)?,
457 duration_ms: row.get(3)?,
458 output_json: row.get(4)?,
459 run_id: row.get(5)?,
460 commit_sha: row.get(6)?,
461 started_at: row.get(7)?,
462 })
463 })?;
464
465 for row in rows {
466 tests.push(row?);
467 }
468 }
469 (None, None) => {
470 let mut stmt = conn.prepare(
472 r#"
473 SELECT test_name, suite_name, full_name, duration_ms,
474 output_json, run_id, commit_sha, started_at
475 FROM failing_tests
476 ORDER BY started_at DESC
477 LIMIT ?
478 "#,
479 )?;
480
481 let rows = stmt.query_map([limit as i64], |row| {
482 Ok(FailingTest {
483 test_name: row.get(0)?,
484 suite_name: row.get(1)?,
485 full_name: row.get(2)?,
486 duration_ms: row.get(3)?,
487 output_json: row.get(4)?,
488 run_id: row.get(5)?,
489 commit_sha: row.get(6)?,
490 started_at: row.get(7)?,
491 })
492 })?;
493
494 for row in rows {
495 tests.push(row?);
496 }
497 }
498 }
499
500 Ok(tests)
501}
502
503pub fn get_activity_summary(conn: &Connection, days: u32) -> Result<ActivitySummary, QueryError> {
514 let since = format!("-{} days", days);
515
516 let commit_count: i64 = conn.query_row(
517 "SELECT COUNT(*) FROM commits WHERE timestamp >= datetime('now', ?)",
518 [&since],
519 |row| row.get(0),
520 )?;
521
522 let test_run_count: i64 = conn.query_row(
524 "SELECT COUNT(*) FROM test_runs WHERE started_at >= datetime('now', ?)",
525 [&since],
526 |row| row.get(0),
527 )?;
528
529 let session_count: i64 = conn.query_row(
531 "SELECT COUNT(*) FROM copilot_sessions WHERE created_at >= datetime('now', ?)",
532 [&since],
533 |row| row.get(0),
534 )?;
535
536 let failing_test_count: i64 = conn.query_row(
538 r#"
539 SELECT COUNT(*)
540 FROM test_results tr
541 JOIN test_runs r ON r.id = tr.run_id
542 WHERE r.started_at >= datetime('now', ?)
543 AND tr.outcome IN ('failed', 'timedout')
544 "#,
545 [&since],
546 |row| row.get(0),
547 )?;
548
549 Ok(ActivitySummary {
550 days,
551 commits: commit_count as u64,
552 test_runs: test_run_count as u64,
553 copilot_sessions: session_count as u64,
554 failing_tests: failing_test_count as u64,
555 })
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct ActivitySummary {
561 pub days: u32,
563 pub commits: u64,
565 pub test_runs: u64,
567 pub copilot_sessions: u64,
569 pub failing_tests: u64,
571}
572
573pub fn get_commit_with_tests(
584 conn: &Connection,
585 commit_sha: &str,
586) -> Result<Option<CommitWithTests>, QueryError> {
587 let commit: Option<(String, String, String, String, String, Option<String>)> = conn
589 .query_row(
590 r#"
591 SELECT id, sha, message, author, timestamp, diff_json
592 FROM commits
593 WHERE sha LIKE ? || '%'
594 LIMIT 1
595 "#,
596 [commit_sha],
597 |row| {
598 Ok((
599 row.get(0)?,
600 row.get(1)?,
601 row.get(2)?,
602 row.get(3)?,
603 row.get(4)?,
604 row.get(5)?,
605 ))
606 },
607 )
608 .optional()?;
609
610 let Some((id, sha, message, author, timestamp, diff_json)) = commit else {
611 return Ok(None);
612 };
613
614 let files: Vec<String> = match diff_json {
616 Some(ref json) => {
617 serde_json::from_str::<serde_json::Value>(json)
619 .ok()
620 .and_then(|v| {
621 if let Some(files) = v.get("files") {
623 files.as_array().map(|arr| {
624 arr.iter()
625 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
626 .map(String::from)
627 .collect()
628 })
629 } else {
630 None
631 }
632 })
633 .unwrap_or_default()
634 }
635 None => Vec::new(),
636 };
637
638 let mut stmt = conn.prepare(
640 r#"
641 SELECT r.id, r.started_at, r.passed_count, r.failed_count, r.ignored_count
642 FROM test_runs r
643 WHERE r.commit_sha = ?
644 ORDER BY r.started_at DESC
645 "#,
646 )?;
647
648 let test_runs: Vec<TestRunSummary> = stmt
649 .query_map([&sha], |row| {
650 Ok(TestRunSummary {
651 id: row.get(0)?,
652 timestamp: row.get(1)?,
653 passed: row.get(2)?,
654 failed: row.get(3)?,
655 skipped: row.get(4)?,
656 })
657 })?
658 .filter_map(Result::ok)
659 .collect();
660
661 Ok(Some(CommitWithTests {
662 id,
663 sha,
664 message,
665 author,
666 timestamp,
667 files,
668 test_runs,
669 }))
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct CommitWithTests {
675 pub id: String,
677 pub sha: String,
679 pub message: String,
681 pub author: String,
683 pub timestamp: String,
685 pub files: Vec<String>,
687 pub test_runs: Vec<TestRunSummary>,
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct TestRunSummary {
694 pub id: String,
696 pub timestamp: String,
698 pub passed: i32,
700 pub failed: i32,
702 pub skipped: i32,
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use crate::migrations;
710
711 fn setup_db() -> Connection {
712 let conn = Connection::open_in_memory().expect("create db");
713 migrations::migrate(&conn).expect("migrate");
714 conn
715 }
716
717 #[test]
718 fn test_get_timeline_empty() {
719 let conn = setup_db();
720 let events = get_timeline(&conn, 10, None).expect("timeline");
721 assert!(events.is_empty());
722 }
723
724 #[test]
725 fn test_search_commits_empty_query() {
726 let conn = setup_db();
727 let result = search_commits(&conn, "", 10);
728 assert!(matches!(result, Err(QueryError::InvalidParameter(_))));
729 }
730
731 #[test]
732 fn test_search_messages_empty_query() {
733 let conn = setup_db();
734 let result = search_messages(&conn, "", 10);
735 assert!(matches!(result, Err(QueryError::InvalidParameter(_))));
736 }
737
738 #[test]
739 fn test_get_failing_tests_empty() {
740 let conn = setup_db();
741 let tests = get_failing_tests(&conn, 10, None, None).expect("failing tests");
742 assert!(tests.is_empty());
743 }
744
745 #[test]
746 fn test_get_activity_summary() {
747 let conn = setup_db();
748 let summary = get_activity_summary(&conn, 7).expect("activity summary");
749 assert_eq!(summary.days, 7);
750 assert_eq!(summary.commits, 0);
751 assert_eq!(summary.test_runs, 0);
752 assert_eq!(summary.copilot_sessions, 0);
753 assert_eq!(summary.failing_tests, 0);
754 }
755
756 #[test]
757 fn test_get_commit_with_tests_not_found() {
758 let conn = setup_db();
759 let result = get_commit_with_tests(&conn, "nonexistent").expect("query");
760 assert!(result.is_none());
761 }
762
763 #[test]
764 fn test_search_with_data() {
765 let conn = setup_db();
766
767 conn.execute(
769 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-1', 'test', '/test', datetime('now'), datetime('now'))",
770 [],
771 )
772 .expect("insert workspace");
773
774 conn.execute(
776 r#"
777 INSERT INTO commits (id, workspace_id, sha, message, author, timestamp, created_at)
778 VALUES ('c-1', 'ws-1', 'abc123def456789012345678901234567890abcd', 'Fix important bug in parser', 'Test Author', datetime('now'), datetime('now'))
779 "#,
780 [],
781 )
782 .expect("insert commit");
783
784 let results = search_commits(&conn, "parser", 10).expect("search");
786 assert_eq!(results.len(), 1);
787 assert!(results[0].snippet.contains("parser"));
788 }
789
790 #[test]
791 fn test_timeline_with_data() {
792 let conn = setup_db();
793
794 conn.execute(
796 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-1', 'test', '/test', datetime('now'), datetime('now'))",
797 [],
798 )
799 .expect("insert workspace");
800
801 conn.execute(
803 r#"
804 INSERT INTO commits (id, workspace_id, sha, message, author, timestamp, created_at)
805 VALUES ('c-1', 'ws-1', 'abc123def456789012345678901234567890abcd', 'Test commit', 'Author', datetime('now'), datetime('now'))
806 "#,
807 [],
808 )
809 .expect("insert commit");
810
811 let events = get_timeline(&conn, 10, None).expect("timeline");
813 assert_eq!(events.len(), 1);
814 assert_eq!(events[0].event_type, "commit");
815 }
816
817 #[test]
818 fn test_failing_tests_with_data() {
819 let conn = setup_db();
820
821 conn.execute(
823 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-1', 'test', '/test', datetime('now'), datetime('now'))",
824 [],
825 )
826 .expect("insert workspace");
827
828 conn.execute(
830 r#"
831 INSERT INTO test_runs (id, workspace_id, started_at, passed_count, failed_count, ignored_count)
832 VALUES ('tr-1', 'ws-1', datetime('now'), 5, 2, 0)
833 "#,
834 [],
835 )
836 .expect("insert test run");
837
838 conn.execute(
840 r#"
841 INSERT INTO test_results (id, run_id, suite_name, test_name, outcome, duration_ms, created_at)
842 VALUES ('r-1', 'tr-1', 'hindsight-mcp', 'test_something', 'failed', 1500, datetime('now'))
843 "#,
844 [],
845 )
846 .expect("insert test result");
847
848 let tests = get_failing_tests(&conn, 10, None, None).expect("failing tests");
850 assert_eq!(tests.len(), 1);
851 assert_eq!(tests[0].suite_name, "hindsight-mcp");
852 assert_eq!(tests[0].full_name, "test_something");
853 }
854
855 #[test]
856 fn test_resolve_workspace_filter_by_path() {
857 let conn = setup_db();
858
859 conn.execute(
861 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-123', 'test', '/test/workspace', datetime('now'), datetime('now'))",
862 [],
863 )
864 .expect("insert workspace");
865
866 let result = resolve_workspace_filter(&conn, "/test/workspace").expect("resolve");
868 assert_eq!(result, Some("ws-123".to_string()));
869 }
870
871 #[test]
872 fn test_resolve_workspace_filter_by_id() {
873 let conn = setup_db();
874
875 conn.execute(
877 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-456', 'test', '/another/path', datetime('now'), datetime('now'))",
878 [],
879 )
880 .expect("insert workspace");
881
882 let result = resolve_workspace_filter(&conn, "ws-456").expect("resolve");
884 assert_eq!(result, Some("ws-456".to_string()));
885 }
886
887 #[test]
888 fn test_resolve_workspace_filter_not_found() {
889 let conn = setup_db();
890
891 let result = resolve_workspace_filter(&conn, "/nonexistent/path").expect("resolve");
893 assert_eq!(result, None);
894 }
895
896 #[test]
897 fn test_timeline_with_workspace_path_filter() {
898 let conn = setup_db();
899
900 conn.execute(
902 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-1', 'test', '/my/workspace', datetime('now'), datetime('now'))",
903 [],
904 )
905 .expect("insert workspace");
906
907 conn.execute(
909 r#"
910 INSERT INTO commits (id, workspace_id, sha, message, author, timestamp, created_at)
911 VALUES ('c-1', 'ws-1', 'abc123def456789012345678901234567890abcd', 'Test commit', 'Author', datetime('now'), datetime('now'))
912 "#,
913 [],
914 )
915 .expect("insert commit");
916
917 let events = get_timeline(&conn, 10, Some("/my/workspace")).expect("timeline");
919 assert_eq!(events.len(), 1);
920 assert_eq!(events[0].event_type, "commit");
921 assert_eq!(events[0].workspace_id, "ws-1");
922 }
923
924 #[test]
925 fn test_failing_tests_with_workspace_path_filter() {
926 let conn = setup_db();
927
928 conn.execute(
930 "INSERT INTO workspaces (id, name, path, created_at, updated_at) VALUES ('ws-1', 'test', '/my/workspace', datetime('now'), datetime('now'))",
931 [],
932 )
933 .expect("insert workspace");
934
935 conn.execute(
937 r#"
938 INSERT INTO test_runs (id, workspace_id, started_at, passed_count, failed_count, ignored_count)
939 VALUES ('tr-1', 'ws-1', datetime('now'), 5, 1, 0)
940 "#,
941 [],
942 )
943 .expect("insert test run");
944
945 conn.execute(
947 r#"
948 INSERT INTO test_results (id, run_id, suite_name, test_name, outcome, duration_ms, created_at)
949 VALUES ('r-1', 'tr-1', 'my-crate', 'test_fails', 'failed', 100, datetime('now'))
950 "#,
951 [],
952 )
953 .expect("insert test result");
954
955 let tests =
957 get_failing_tests(&conn, 10, Some("/my/workspace"), None).expect("failing tests");
958 assert_eq!(tests.len(), 1);
959 assert_eq!(tests[0].suite_name, "my-crate");
960 }
961}