hindsight_mcp/
queries.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! Query helper functions for hindsight-mcp database
5//!
6//! This module provides high-level query functions for searching and
7//! retrieving development history data from the SQLite database.
8
9use rusqlite::{Connection, OptionalExtension, params};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Query errors
14#[derive(Debug, Error)]
15pub enum QueryError {
16    /// SQLite error during query
17    #[error("SQLite error: {0}")]
18    Sqlite(#[from] rusqlite::Error),
19
20    /// Invalid parameter
21    #[error("Invalid parameter: {0}")]
22    InvalidParameter(String),
23}
24
25/// A timeline event representing activity in the workspace
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TimelineEvent {
28    /// Event type: 'commit', 'test_run', or 'copilot_message'
29    pub event_type: String,
30    /// Unique identifier for the event (UUID as string)
31    pub event_id: String,
32    /// Workspace ID
33    pub workspace_id: String,
34    /// ISO 8601 timestamp of the event
35    pub event_timestamp: String,
36    /// Brief summary of the event
37    pub summary: String,
38    /// JSON details
39    pub details_json: Option<String>,
40}
41
42/// A search result from full-text search
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SearchResult {
45    /// Type of result: 'commit' or 'copilot_message'
46    pub result_type: String,
47    /// Unique identifier (UUID as string)
48    pub id: String,
49    /// Matching content snippet
50    pub snippet: String,
51    /// Relevance rank (lower is better)
52    pub rank: f64,
53    /// ISO 8601 timestamp
54    pub timestamp: String,
55}
56
57/// A failing test result
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FailingTest {
60    /// Test name (UUID)
61    pub test_name: String,
62    /// Suite name
63    pub suite_name: String,
64    /// Full test name
65    pub full_name: String,
66    /// Duration in milliseconds
67    pub duration_ms: Option<i64>,
68    /// Output JSON if available
69    pub output_json: Option<String>,
70    /// Test run ID
71    pub run_id: String,
72    /// Commit SHA
73    pub commit_sha: Option<String>,
74    /// ISO 8601 timestamp
75    pub started_at: String,
76}
77
78/// Get workspace ID from a workspace path
79///
80/// The workspace filter can be either a workspace ID (UUID) or a filesystem path.
81/// This function looks up the path in the workspaces table to find the corresponding ID.
82/// If the filter doesn't match a path, it's assumed to be a workspace ID and returned as-is.
83///
84/// # Arguments
85///
86/// * `conn` - Database connection
87/// * `filter` - Workspace ID or path to resolve
88///
89/// # Returns
90///
91/// The workspace ID, or None if the path doesn't exist and wasn't a valid ID.
92fn resolve_workspace_filter(conn: &Connection, filter: &str) -> Result<Option<String>, QueryError> {
93    // First, try to look up by path
94    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            // Not a path - check if it's a valid workspace ID
104            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
118/// Query the timeline view for recent activity
119///
120/// # Arguments
121///
122/// * `conn` - Database connection
123/// * `limit` - Maximum number of events to return
124/// * `workspace_filter` - Optional workspace path or ID to filter by
125///
126/// # Errors
127///
128/// Returns an error if the query fails.
129pub 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    // Resolve workspace filter (path or ID) to workspace ID
137    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
196/// Search commits using FTS5 full-text search
197///
198/// # Arguments
199///
200/// * `conn` - Database connection
201/// * `query` - Search query (FTS5 syntax)
202/// * `limit` - Maximum number of results
203///
204/// # Errors
205///
206/// Returns an error if the query fails.
207pub 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    // FTS5 uses rowid which matches the internal SQLite rowid of commits table
219    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
251/// Search Copilot messages using FTS5 full-text search
252///
253/// # Arguments
254///
255/// * `conn` - Database connection
256/// * `query` - Search query (FTS5 syntax)
257/// * `limit` - Maximum number of results
258///
259/// # Errors
260///
261/// Returns an error if the query fails.
262pub 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    // FTS5 uses rowid which matches the internal SQLite rowid of copilot_messages table
274    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
306/// Combined search across commits and messages
307///
308/// # Arguments
309///
310/// * `conn` - Database connection
311/// * `query` - Search query (FTS5 syntax)
312/// * `limit` - Maximum number of results per type
313///
314/// # Errors
315///
316/// Returns an error if the query fails.
317pub 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    // Search commits
325    results.extend(search_commits(conn, query, limit)?);
326
327    // Search messages
328    results.extend(search_messages(conn, query, limit)?);
329
330    // Sort by rank (lower is better)
331    results.sort_by(|a, b| {
332        a.rank
333            .partial_cmp(&b.rank)
334            .unwrap_or(std::cmp::Ordering::Equal)
335    });
336
337    // Limit total results
338    results.truncate(limit);
339
340    Ok(results)
341}
342
343/// Get failing tests from the failing_tests view
344///
345/// # Arguments
346///
347/// * `conn` - Database connection
348/// * `limit` - Maximum number of results
349/// * `workspace_filter` - Optional workspace path or ID to filter by (via test_runs)
350/// * `commit_filter` - Optional commit SHA (full or partial) to filter by
351///
352/// # Errors
353///
354/// Returns an error if the query fails.
355pub 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    // Resolve workspace filter (path or ID) to workspace ID
364    let resolved_workspace_id = match workspace_filter {
365        Some(filter) => resolve_workspace_filter(conn, filter)?,
366        None => None,
367    };
368
369    // Build query based on filters
370    // The failing_tests view columns are:
371    // test_name (from tr.id), suite_name, full_name (from tr.test_name),
372    // duration_ms, output_json, run_id, commit_sha, started_at
373    match (resolved_workspace_id, commit_filter) {
374        (Some(workspace_id), Some(commit)) => {
375            // Filter by both workspace and commit
376            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            // Filter by workspace only
409            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            // Filter by commit only
440            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            // No filters
471            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
503/// Get recent activity summary
504///
505/// # Arguments
506///
507/// * `conn` - Database connection
508/// * `days` - Number of days to look back
509///
510/// # Errors
511///
512/// Returns an error if the query fails.
513pub 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    // test_runs uses started_at, not timestamp
523    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    // copilot_sessions uses created_at, not start_time
530    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    // test_results uses outcome, not status
537    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/// Summary of recent activity
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct ActivitySummary {
561    /// Number of days covered
562    pub days: u32,
563    /// Number of commits
564    pub commits: u64,
565    /// Number of test runs
566    pub test_runs: u64,
567    /// Number of Copilot sessions
568    pub copilot_sessions: u64,
569    /// Number of failing tests
570    pub failing_tests: u64,
571}
572
573/// Get commits with their associated test results
574///
575/// # Arguments
576///
577/// * `conn` - Database connection
578/// * `commit_sha` - Git commit SHA (or prefix)
579///
580/// # Errors
581///
582/// Returns an error if the query fails.
583pub fn get_commit_with_tests(
584    conn: &Connection,
585    commit_sha: &str,
586) -> Result<Option<CommitWithTests>, QueryError> {
587    // Find the commit - schema uses diff_json for file changes
588    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    // Parse diff JSON to extract file paths
615    let files: Vec<String> = match diff_json {
616        Some(ref json) => {
617            // Try to parse as object with "files" key, or as array of file paths
618            serde_json::from_str::<serde_json::Value>(json)
619                .ok()
620                .and_then(|v| {
621                    // Try {"files": [...]} format
622                    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    // Get associated test runs - schema uses started_at, passed_count, failed_count, ignored_count
639    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/// A commit with associated test information
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct CommitWithTests {
675    /// Database ID (UUID)
676    pub id: String,
677    /// Git commit SHA
678    pub sha: String,
679    /// Commit message
680    pub message: String,
681    /// Author name
682    pub author: String,
683    /// ISO 8601 timestamp
684    pub timestamp: String,
685    /// Changed files
686    pub files: Vec<String>,
687    /// Associated test runs
688    pub test_runs: Vec<TestRunSummary>,
689}
690
691/// Summary of a test run
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct TestRunSummary {
694    /// Database ID (UUID)
695    pub id: String,
696    /// ISO 8601 timestamp
697    pub timestamp: String,
698    /// Number of passed tests
699    pub passed: i32,
700    /// Number of failed tests
701    pub failed: i32,
702    /// Number of skipped tests
703    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        // Insert a workspace with all required columns
768        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        // Insert a commit with all required columns
775        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        // Search for the commit
785        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        // Insert a workspace with all required columns
795        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        // Insert a commit with all required columns
802        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        // Get timeline
812        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        // Insert a workspace with all required columns
822        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        // Insert a test run with correct column names
829        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        // Insert a failing test with correct column names
839        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        // Get failing tests
849        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        // Insert a workspace
860        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        // Resolve by path should return the workspace ID
867        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        // Insert a workspace
876        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        // Resolve by ID should return the same ID
883        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        // Resolve non-existent path or ID should return None
892        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        // Insert a workspace with a specific path
901        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        // Insert a commit for this workspace
908        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        // Get timeline filtered by path (not ID) - this tests the bug fix
918        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        // Insert a workspace with a specific path
929        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        // Insert a test run
936        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        // Insert a failing test
946        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        // Get failing tests filtered by path (not ID) - this tests the bug fix
956        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}