Skip to main content

ai_chain/
db.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use rusqlite::{params, Connection};
4use std::path::Path;
5
6use crate::models::{Comment, Issue, Session};
7
8const SCHEMA_VERSION: i32 = 8;
9
10pub struct Database {
11    conn: Connection,
12}
13
14impl Database {
15    pub fn open(path: &Path) -> Result<Self> {
16        let conn = Connection::open(path).context("Failed to open database")?;
17        let db = Database { conn };
18        db.init_schema()?;
19        Ok(db)
20    }
21
22    /// Execute a closure within a database transaction.
23    /// If the closure returns Ok, the transaction is committed.
24    /// If the closure returns Err, the transaction is rolled back.
25    pub fn transaction<T, F>(&self, f: F) -> Result<T>
26    where
27        F: FnOnce() -> Result<T>,
28    {
29        self.conn.execute("BEGIN TRANSACTION", [])?;
30        match f() {
31            Ok(result) => {
32                self.conn.execute("COMMIT", [])?;
33                Ok(result)
34            }
35            Err(e) => {
36                let _ = self.conn.execute("ROLLBACK", []);
37                Err(e)
38            }
39        }
40    }
41
42    fn init_schema(&self) -> Result<()> {
43        // Check if we need to initialize
44        let version: i32 = self
45            .conn
46            .query_row(
47                "SELECT COALESCE(MAX(version), 0) FROM pragma_user_version",
48                [],
49                |row| row.get(0),
50            )
51            .unwrap_or(0);
52
53        if version < SCHEMA_VERSION {
54            self.conn.execute_batch(
55                r#"
56                -- Core issues table
57                CREATE TABLE IF NOT EXISTS issues (
58                    id INTEGER PRIMARY KEY AUTOINCREMENT,
59                    title TEXT NOT NULL,
60                    description TEXT,
61                    status TEXT NOT NULL DEFAULT 'open',
62                    priority TEXT NOT NULL DEFAULT 'medium',
63                    parent_id INTEGER,
64                    created_at TEXT NOT NULL,
65                    updated_at TEXT NOT NULL,
66                    closed_at TEXT,
67                    FOREIGN KEY (parent_id) REFERENCES issues(id) ON DELETE CASCADE
68                );
69
70                -- Labels (many-to-many)
71                CREATE TABLE IF NOT EXISTS labels (
72                    issue_id INTEGER NOT NULL,
73                    label TEXT NOT NULL,
74                    PRIMARY KEY (issue_id, label),
75                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
76                );
77
78                -- Dependencies (blocker blocks blocked)
79                CREATE TABLE IF NOT EXISTS dependencies (
80                    blocker_id INTEGER NOT NULL,
81                    blocked_id INTEGER NOT NULL,
82                    PRIMARY KEY (blocker_id, blocked_id),
83                    FOREIGN KEY (blocker_id) REFERENCES issues(id) ON DELETE CASCADE,
84                    FOREIGN KEY (blocked_id) REFERENCES issues(id) ON DELETE CASCADE
85                );
86
87                -- Comments
88                CREATE TABLE IF NOT EXISTS comments (
89                    id INTEGER PRIMARY KEY AUTOINCREMENT,
90                    issue_id INTEGER NOT NULL,
91                    content TEXT NOT NULL,
92                    created_at TEXT NOT NULL,
93                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
94                );
95
96                -- Sessions (for context preservation)
97                CREATE TABLE IF NOT EXISTS sessions (
98                    id INTEGER PRIMARY KEY AUTOINCREMENT,
99                    started_at TEXT NOT NULL,
100                    ended_at TEXT,
101                    active_issue_id INTEGER,
102                    handoff_notes TEXT,
103                    FOREIGN KEY (active_issue_id) REFERENCES issues(id) ON DELETE SET NULL
104                );
105
106                -- Time tracking
107                CREATE TABLE IF NOT EXISTS time_entries (
108                    id INTEGER PRIMARY KEY AUTOINCREMENT,
109                    issue_id INTEGER NOT NULL,
110                    started_at TEXT NOT NULL,
111                    ended_at TEXT,
112                    duration_seconds INTEGER,
113                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
114                );
115
116                -- Relations (related issues, bidirectional)
117                CREATE TABLE IF NOT EXISTS relations (
118                    issue_id_1 INTEGER NOT NULL,
119                    issue_id_2 INTEGER NOT NULL,
120                    created_at TEXT NOT NULL,
121                    PRIMARY KEY (issue_id_1, issue_id_2),
122                    FOREIGN KEY (issue_id_1) REFERENCES issues(id) ON DELETE CASCADE,
123                    FOREIGN KEY (issue_id_2) REFERENCES issues(id) ON DELETE CASCADE
124                );
125
126                -- Milestones
127                CREATE TABLE IF NOT EXISTS milestones (
128                    id INTEGER PRIMARY KEY AUTOINCREMENT,
129                    name TEXT NOT NULL,
130                    description TEXT,
131                    status TEXT NOT NULL DEFAULT 'open',
132                    created_at TEXT NOT NULL,
133                    closed_at TEXT
134                );
135
136                -- Milestone-Issue relationship (many-to-many)
137                CREATE TABLE IF NOT EXISTS milestone_issues (
138                    milestone_id INTEGER NOT NULL,
139                    issue_id INTEGER NOT NULL,
140                    PRIMARY KEY (milestone_id, issue_id),
141                    FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE CASCADE,
142                    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
143                );
144
145                -- Indexes
146                CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
147                CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
148                CREATE INDEX IF NOT EXISTS idx_labels_issue ON labels(issue_id);
149                CREATE INDEX IF NOT EXISTS idx_comments_issue ON comments(issue_id);
150                CREATE INDEX IF NOT EXISTS idx_deps_blocker ON dependencies(blocker_id);
151                CREATE INDEX IF NOT EXISTS idx_deps_blocked ON dependencies(blocked_id);
152                CREATE INDEX IF NOT EXISTS idx_issues_parent ON issues(parent_id);
153                CREATE INDEX IF NOT EXISTS idx_time_entries_issue ON time_entries(issue_id);
154                CREATE INDEX IF NOT EXISTS idx_relations_1 ON relations(issue_id_1);
155                CREATE INDEX IF NOT EXISTS idx_relations_2 ON relations(issue_id_2);
156                CREATE INDEX IF NOT EXISTS idx_milestone_issues_m ON milestone_issues(milestone_id);
157                CREATE INDEX IF NOT EXISTS idx_milestone_issues_i ON milestone_issues(issue_id);
158                "#,
159            )?;
160
161            // Migration: add parent_id column if upgrading from v1
162            let _ = self.conn.execute(
163                "ALTER TABLE issues ADD COLUMN parent_id INTEGER REFERENCES issues(id) ON DELETE CASCADE",
164                [],
165            );
166
167            // Migration v7: Recreate sessions table with ON DELETE SET NULL for active_issue_id
168            // This ensures deleting an issue clears the session reference instead of failing
169            if version < 7 {
170                let _ = self.conn.execute_batch(
171                    r#"
172                    CREATE TABLE IF NOT EXISTS sessions_new (
173                        id INTEGER PRIMARY KEY AUTOINCREMENT,
174                        started_at TEXT NOT NULL,
175                        ended_at TEXT,
176                        active_issue_id INTEGER,
177                        handoff_notes TEXT,
178                        FOREIGN KEY (active_issue_id) REFERENCES issues(id) ON DELETE SET NULL
179                    );
180                    INSERT OR IGNORE INTO sessions_new SELECT * FROM sessions;
181                    DROP TABLE IF EXISTS sessions;
182                    ALTER TABLE sessions_new RENAME TO sessions;
183                    "#,
184                );
185            }
186
187            // Migration v8: Add last_action column to sessions table
188            if version < 8 {
189                let _ = self
190                    .conn
191                    .execute("ALTER TABLE sessions ADD COLUMN last_action TEXT", []);
192            }
193
194            self.conn
195                .execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
196        }
197
198        // Enable foreign keys
199        self.conn.execute("PRAGMA foreign_keys = ON", [])?;
200
201        Ok(())
202    }
203
204    // Issue CRUD
205    pub fn create_issue(
206        &self,
207        title: &str,
208        description: Option<&str>,
209        priority: &str,
210    ) -> Result<i64> {
211        self.create_issue_with_parent(title, description, priority, None)
212    }
213
214    pub fn create_subissue(
215        &self,
216        parent_id: i64,
217        title: &str,
218        description: Option<&str>,
219        priority: &str,
220    ) -> Result<i64> {
221        self.create_issue_with_parent(title, description, priority, Some(parent_id))
222    }
223
224    fn create_issue_with_parent(
225        &self,
226        title: &str,
227        description: Option<&str>,
228        priority: &str,
229        parent_id: Option<i64>,
230    ) -> Result<i64> {
231        let now = Utc::now().to_rfc3339();
232        self.conn.execute(
233            "INSERT INTO issues (title, description, priority, parent_id, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?5)",
234            params![title, description, priority, parent_id, now],
235        )?;
236        Ok(self.conn.last_insert_rowid())
237    }
238
239    pub fn get_subissues(&self, parent_id: i64) -> Result<Vec<Issue>> {
240        let mut stmt = self.conn.prepare(
241            "SELECT id, title, description, status, priority, parent_id, created_at, updated_at, closed_at FROM issues WHERE parent_id = ?1 ORDER BY id",
242        )?;
243
244        let issues = stmt
245            .query_map([parent_id], issue_from_row)?
246            .collect::<std::result::Result<Vec<_>, _>>()?;
247
248        Ok(issues)
249    }
250
251    pub fn get_issue(&self, id: i64) -> Result<Option<Issue>> {
252        let mut stmt = self.conn.prepare(
253            "SELECT id, title, description, status, priority, parent_id, created_at, updated_at, closed_at FROM issues WHERE id = ?1",
254        )?;
255
256        let issue = stmt.query_row([id], issue_from_row).ok();
257
258        Ok(issue)
259    }
260
261    /// Get an issue by ID, returning an error if not found.
262    /// Use this instead of get_issue when you need the issue to exist.
263    pub fn require_issue(&self, id: i64) -> Result<Issue> {
264        self.get_issue(id)?
265            .ok_or_else(|| anyhow::anyhow!("Issue #{} not found", id))
266    }
267
268    pub fn list_issues(
269        &self,
270        status_filter: Option<&str>,
271        label_filter: Option<&str>,
272        priority_filter: Option<&str>,
273    ) -> Result<Vec<Issue>> {
274        let mut sql = String::from(
275            "SELECT DISTINCT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at FROM issues i",
276        );
277        let mut conditions = Vec::new();
278        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
279
280        if label_filter.is_some() {
281            sql.push_str(" JOIN labels l ON i.id = l.issue_id");
282        }
283
284        if let Some(status) = status_filter {
285            if status != "all" {
286                conditions.push("i.status = ?".to_string());
287                params_vec.push(Box::new(status.to_string()));
288            }
289        }
290
291        if let Some(label) = label_filter {
292            conditions.push("l.label = ?".to_string());
293            params_vec.push(Box::new(label.to_string()));
294        }
295
296        if let Some(priority) = priority_filter {
297            conditions.push("i.priority = ?".to_string());
298            params_vec.push(Box::new(priority.to_string()));
299        }
300
301        if !conditions.is_empty() {
302            sql.push_str(" WHERE ");
303            sql.push_str(&conditions.join(" AND "));
304        }
305
306        sql.push_str(" ORDER BY i.id DESC");
307
308        let mut stmt = self.conn.prepare(&sql)?;
309        let params_refs: Vec<&dyn rusqlite::ToSql> =
310            params_vec.iter().map(|p| p.as_ref()).collect();
311
312        let issues = stmt
313            .query_map(params_refs.as_slice(), issue_from_row)?
314            .collect::<std::result::Result<Vec<_>, _>>()?;
315
316        Ok(issues)
317    }
318
319    pub fn update_issue(
320        &self,
321        id: i64,
322        title: Option<&str>,
323        description: Option<&str>,
324        priority: Option<&str>,
325    ) -> Result<bool> {
326        let now = Utc::now().to_rfc3339();
327        let mut updates = vec!["updated_at = ?1".to_string()];
328        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
329
330        if let Some(t) = title {
331            updates.push(format!("title = ?{}", params_vec.len() + 1));
332            params_vec.push(Box::new(t.to_string()));
333        }
334
335        if let Some(d) = description {
336            updates.push(format!("description = ?{}", params_vec.len() + 1));
337            params_vec.push(Box::new(d.to_string()));
338        }
339
340        if let Some(p) = priority {
341            updates.push(format!("priority = ?{}", params_vec.len() + 1));
342            params_vec.push(Box::new(p.to_string()));
343        }
344
345        params_vec.push(Box::new(id));
346        let sql = format!(
347            "UPDATE issues SET {} WHERE id = ?{}",
348            updates.join(", "),
349            params_vec.len()
350        );
351
352        let params_refs: Vec<&dyn rusqlite::ToSql> =
353            params_vec.iter().map(|p| p.as_ref()).collect();
354        let rows = self.conn.execute(&sql, params_refs.as_slice())?;
355        Ok(rows > 0)
356    }
357
358    pub fn close_issue(&self, id: i64) -> Result<bool> {
359        let now = Utc::now().to_rfc3339();
360        let rows = self.conn.execute(
361            "UPDATE issues SET status = 'closed', closed_at = ?1, updated_at = ?1 WHERE id = ?2",
362            params![now, id],
363        )?;
364        Ok(rows > 0)
365    }
366
367    pub fn reopen_issue(&self, id: i64) -> Result<bool> {
368        let now = Utc::now().to_rfc3339();
369        let rows = self.conn.execute(
370            "UPDATE issues SET status = 'open', closed_at = NULL, updated_at = ?1 WHERE id = ?2",
371            params![now, id],
372        )?;
373        Ok(rows > 0)
374    }
375
376    pub fn delete_issue(&self, id: i64) -> Result<bool> {
377        let rows = self
378            .conn
379            .execute("DELETE FROM issues WHERE id = ?1", [id])?;
380        Ok(rows > 0)
381    }
382
383    // Labels
384    pub fn add_label(&self, issue_id: i64, label: &str) -> Result<bool> {
385        let result = self.conn.execute(
386            "INSERT OR IGNORE INTO labels (issue_id, label) VALUES (?1, ?2)",
387            params![issue_id, label],
388        )?;
389        Ok(result > 0)
390    }
391
392    pub fn remove_label(&self, issue_id: i64, label: &str) -> Result<bool> {
393        let rows = self.conn.execute(
394            "DELETE FROM labels WHERE issue_id = ?1 AND label = ?2",
395            params![issue_id, label],
396        )?;
397        Ok(rows > 0)
398    }
399
400    pub fn get_labels(&self, issue_id: i64) -> Result<Vec<String>> {
401        let mut stmt = self
402            .conn
403            .prepare("SELECT label FROM labels WHERE issue_id = ?1 ORDER BY label")?;
404        let labels = stmt
405            .query_map([issue_id], |row| row.get(0))?
406            .collect::<std::result::Result<Vec<String>, _>>()?;
407        Ok(labels)
408    }
409
410    // Comments
411    pub fn add_comment(&self, issue_id: i64, content: &str) -> Result<i64> {
412        let now = Utc::now().to_rfc3339();
413        self.conn.execute(
414            "INSERT INTO comments (issue_id, content, created_at) VALUES (?1, ?2, ?3)",
415            params![issue_id, content, now],
416        )?;
417        Ok(self.conn.last_insert_rowid())
418    }
419
420    pub fn get_comments(&self, issue_id: i64) -> Result<Vec<Comment>> {
421        let mut stmt = self.conn.prepare(
422            "SELECT id, issue_id, content, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at",
423        )?;
424        let comments = stmt
425            .query_map([issue_id], |row| {
426                Ok(Comment {
427                    id: row.get(0)?,
428                    issue_id: row.get(1)?,
429                    content: row.get(2)?,
430                    created_at: parse_datetime(row.get::<_, String>(3)?),
431                })
432            })?
433            .collect::<std::result::Result<Vec<_>, _>>()?;
434        Ok(comments)
435    }
436
437    // Dependencies
438    pub fn add_dependency(&self, blocked_id: i64, blocker_id: i64) -> Result<bool> {
439        // Prevent self-blocking
440        if blocked_id == blocker_id {
441            anyhow::bail!("An issue cannot block itself");
442        }
443
444        // Check for circular dependencies before inserting
445        if self.would_create_cycle(blocked_id, blocker_id)? {
446            anyhow::bail!("Adding this dependency would create a circular dependency chain");
447        }
448
449        let result = self.conn.execute(
450            "INSERT OR IGNORE INTO dependencies (blocker_id, blocked_id) VALUES (?1, ?2)",
451            params![blocker_id, blocked_id],
452        )?;
453        Ok(result > 0)
454    }
455
456    /// Check if adding blocker_id -> blocked_id would create a cycle.
457    /// A cycle exists if blocked_id can already reach blocker_id through existing dependencies.
458    fn would_create_cycle(&self, blocked_id: i64, blocker_id: i64) -> Result<bool> {
459        // If blocked_id can reach blocker_id, then adding blocker_id -> blocked_id creates a cycle
460        let mut visited = std::collections::HashSet::new();
461        let mut stack = vec![blocked_id];
462
463        while let Some(current) = stack.pop() {
464            if current == blocker_id {
465                return Ok(true); // Found a path from blocked_id to blocker_id
466            }
467
468            if visited.insert(current) {
469                // Get all issues that 'current' blocks (issues where current is the blocker)
470                let blocking = self.get_blocking(current)?;
471                for next in blocking {
472                    if !visited.contains(&next) {
473                        stack.push(next);
474                    }
475                }
476            }
477        }
478
479        Ok(false)
480    }
481
482    pub fn remove_dependency(&self, blocked_id: i64, blocker_id: i64) -> Result<bool> {
483        let rows = self.conn.execute(
484            "DELETE FROM dependencies WHERE blocker_id = ?1 AND blocked_id = ?2",
485            params![blocker_id, blocked_id],
486        )?;
487        Ok(rows > 0)
488    }
489
490    pub fn get_blockers(&self, issue_id: i64) -> Result<Vec<i64>> {
491        let mut stmt = self
492            .conn
493            .prepare("SELECT blocker_id FROM dependencies WHERE blocked_id = ?1")?;
494        let blockers = stmt
495            .query_map([issue_id], |row| row.get(0))?
496            .collect::<std::result::Result<Vec<i64>, _>>()?;
497        Ok(blockers)
498    }
499
500    pub fn get_blocking(&self, issue_id: i64) -> Result<Vec<i64>> {
501        let mut stmt = self
502            .conn
503            .prepare("SELECT blocked_id FROM dependencies WHERE blocker_id = ?1")?;
504        let blocking = stmt
505            .query_map([issue_id], |row| row.get(0))?
506            .collect::<std::result::Result<Vec<i64>, _>>()?;
507        Ok(blocking)
508    }
509
510    pub fn list_blocked_issues(&self) -> Result<Vec<Issue>> {
511        let mut stmt = self.conn.prepare(
512            r#"
513            SELECT DISTINCT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
514            FROM issues i
515            JOIN dependencies d ON i.id = d.blocked_id
516            JOIN issues blocker ON d.blocker_id = blocker.id
517            WHERE i.status = 'open' AND blocker.status = 'open'
518            ORDER BY i.id
519            "#,
520        )?;
521
522        let issues = stmt
523            .query_map([], issue_from_row)?
524            .collect::<std::result::Result<Vec<_>, _>>()?;
525
526        Ok(issues)
527    }
528
529    pub fn list_ready_issues(&self) -> Result<Vec<Issue>> {
530        let mut stmt = self.conn.prepare(
531            r#"
532            SELECT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
533            FROM issues i
534            WHERE i.status = 'open'
535            AND NOT EXISTS (
536                SELECT 1 FROM dependencies d
537                JOIN issues blocker ON d.blocker_id = blocker.id
538                WHERE d.blocked_id = i.id AND blocker.status = 'open'
539            )
540            ORDER BY i.id
541            "#,
542        )?;
543
544        let issues = stmt
545            .query_map([], issue_from_row)?
546            .collect::<std::result::Result<Vec<_>, _>>()?;
547
548        Ok(issues)
549    }
550
551    // Sessions
552    pub fn start_session(&self) -> Result<i64> {
553        let now = Utc::now().to_rfc3339();
554        self.conn.execute(
555            "INSERT INTO sessions (started_at) VALUES (?1)",
556            params![now],
557        )?;
558        Ok(self.conn.last_insert_rowid())
559    }
560
561    pub fn end_session(&self, id: i64, notes: Option<&str>) -> Result<bool> {
562        let now = Utc::now().to_rfc3339();
563        let rows = self.conn.execute(
564            "UPDATE sessions SET ended_at = ?1, handoff_notes = ?2 WHERE id = ?3",
565            params![now, notes, id],
566        )?;
567        Ok(rows > 0)
568    }
569
570    pub fn get_current_session(&self) -> Result<Option<Session>> {
571        let mut stmt = self.conn.prepare(
572            "SELECT id, started_at, ended_at, active_issue_id, handoff_notes, last_action FROM sessions WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1",
573        )?;
574
575        let session = stmt
576            .query_row([], |row| {
577                Ok(Session {
578                    id: row.get(0)?,
579                    started_at: parse_datetime(row.get::<_, String>(1)?),
580                    ended_at: row.get::<_, Option<String>>(2)?.map(parse_datetime),
581                    active_issue_id: row.get(3)?,
582                    handoff_notes: row.get(4)?,
583                    last_action: row.get(5)?,
584                })
585            })
586            .ok();
587
588        Ok(session)
589    }
590
591    pub fn get_last_session(&self) -> Result<Option<Session>> {
592        let mut stmt = self.conn.prepare(
593            "SELECT id, started_at, ended_at, active_issue_id, handoff_notes, last_action FROM sessions WHERE ended_at IS NOT NULL ORDER BY id DESC LIMIT 1",
594        )?;
595
596        let session = stmt
597            .query_row([], |row| {
598                Ok(Session {
599                    id: row.get(0)?,
600                    started_at: parse_datetime(row.get::<_, String>(1)?),
601                    ended_at: row.get::<_, Option<String>>(2)?.map(parse_datetime),
602                    active_issue_id: row.get(3)?,
603                    handoff_notes: row.get(4)?,
604                    last_action: row.get(5)?,
605                })
606            })
607            .ok();
608
609        Ok(session)
610    }
611
612    pub fn set_session_issue(&self, session_id: i64, issue_id: i64) -> Result<bool> {
613        let rows = self.conn.execute(
614            "UPDATE sessions SET active_issue_id = ?1 WHERE id = ?2",
615            params![issue_id, session_id],
616        )?;
617        Ok(rows > 0)
618    }
619
620    pub fn set_session_action(&self, session_id: i64, action: &str) -> Result<bool> {
621        let rows = self.conn.execute(
622            "UPDATE sessions SET last_action = ?1 WHERE id = ?2",
623            params![action, session_id],
624        )?;
625        Ok(rows > 0)
626    }
627
628    // Time tracking
629    pub fn start_timer(&self, issue_id: i64) -> Result<i64> {
630        let now = Utc::now().to_rfc3339();
631        self.conn.execute(
632            "INSERT INTO time_entries (issue_id, started_at) VALUES (?1, ?2)",
633            params![issue_id, now],
634        )?;
635        Ok(self.conn.last_insert_rowid())
636    }
637
638    pub fn stop_timer(&self, issue_id: i64) -> Result<bool> {
639        let now = Utc::now();
640        let now_str = now.to_rfc3339();
641
642        // Get the active entry
643        let started_at: Option<String> = self
644            .conn
645            .query_row(
646                "SELECT started_at FROM time_entries WHERE issue_id = ?1 AND ended_at IS NULL",
647                [issue_id],
648                |row| row.get(0),
649            )
650            .ok();
651
652        if let Some(started) = started_at {
653            let start_dt = DateTime::parse_from_rfc3339(&started)
654                .map(|dt| dt.with_timezone(&Utc))
655                .unwrap_or(now);
656            let duration = now.signed_duration_since(start_dt).num_seconds();
657
658            let rows = self.conn.execute(
659                "UPDATE time_entries SET ended_at = ?1, duration_seconds = ?2 WHERE issue_id = ?3 AND ended_at IS NULL",
660                params![now_str, duration, issue_id],
661            )?;
662            Ok(rows > 0)
663        } else {
664            Ok(false)
665        }
666    }
667
668    pub fn get_active_timer(&self) -> Result<Option<(i64, DateTime<Utc>)>> {
669        let result: Option<(i64, String)> = self
670            .conn
671            .query_row(
672                "SELECT issue_id, started_at FROM time_entries WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1",
673                [],
674                |row| Ok((row.get(0)?, row.get(1)?)),
675            )
676            .ok();
677
678        Ok(result.map(|(id, started)| (id, parse_datetime(started))))
679    }
680
681    pub fn get_total_time(&self, issue_id: i64) -> Result<i64> {
682        let total: i64 = self
683            .conn
684            .query_row(
685                "SELECT COALESCE(SUM(duration_seconds), 0) FROM time_entries WHERE issue_id = ?1 AND duration_seconds IS NOT NULL",
686                [issue_id],
687                |row| row.get(0),
688            )
689            .unwrap_or(0);
690        Ok(total)
691    }
692
693    /// Search issues by query string across titles, descriptions, and comments
694    pub fn search_issues(&self, query: &str) -> Result<Vec<Issue>> {
695        // Escape SQL LIKE wildcards to prevent unintended pattern matching
696        let escaped = query.replace('%', "\\%").replace('_', "\\_");
697        let pattern = format!("%{}%", escaped);
698        let mut stmt = self.conn.prepare(
699            r#"
700            SELECT DISTINCT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
701            FROM issues i
702            LEFT JOIN comments c ON i.id = c.issue_id
703            WHERE i.title LIKE ?1 ESCAPE '\' COLLATE NOCASE
704               OR i.description LIKE ?1 ESCAPE '\' COLLATE NOCASE
705               OR c.content LIKE ?1 ESCAPE '\' COLLATE NOCASE
706            ORDER BY i.id DESC
707            "#,
708        )?;
709
710        let issues = stmt
711            .query_map([&pattern], issue_from_row)?
712            .collect::<std::result::Result<Vec<_>, _>>()?;
713
714        Ok(issues)
715    }
716
717    // Relations (bidirectional)
718    pub fn add_relation(&self, issue_id_1: i64, issue_id_2: i64) -> Result<bool> {
719        if issue_id_1 == issue_id_2 {
720            anyhow::bail!("Cannot relate an issue to itself");
721        }
722        // Store with smaller ID first for consistency
723        let (a, b) = if issue_id_1 < issue_id_2 {
724            (issue_id_1, issue_id_2)
725        } else {
726            (issue_id_2, issue_id_1)
727        };
728        let now = Utc::now().to_rfc3339();
729        let result = self.conn.execute(
730            "INSERT OR IGNORE INTO relations (issue_id_1, issue_id_2, created_at) VALUES (?1, ?2, ?3)",
731            params![a, b, now],
732        )?;
733        Ok(result > 0)
734    }
735
736    pub fn remove_relation(&self, issue_id_1: i64, issue_id_2: i64) -> Result<bool> {
737        let (a, b) = if issue_id_1 < issue_id_2 {
738            (issue_id_1, issue_id_2)
739        } else {
740            (issue_id_2, issue_id_1)
741        };
742        let rows = self.conn.execute(
743            "DELETE FROM relations WHERE issue_id_1 = ?1 AND issue_id_2 = ?2",
744            params![a, b],
745        )?;
746        Ok(rows > 0)
747    }
748
749    pub fn update_parent(&self, id: i64, parent_id: Option<i64>) -> Result<bool> {
750        let now = chrono::Utc::now().to_rfc3339();
751        let rows = self.conn.execute(
752            "UPDATE issues SET parent_id = ?1, updated_at = ?2 WHERE id = ?3",
753            params![parent_id, now, id],
754        )?;
755        Ok(rows > 0)
756    }
757
758    pub fn get_related_issues(&self, issue_id: i64) -> Result<Vec<Issue>> {
759        let mut stmt = self.conn.prepare(
760            r#"
761            SELECT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
762            FROM issues i
763            WHERE i.id IN (
764                SELECT issue_id_2 FROM relations WHERE issue_id_1 = ?1
765                UNION
766                SELECT issue_id_1 FROM relations WHERE issue_id_2 = ?1
767            )
768            ORDER BY i.id
769            "#,
770        )?;
771
772        let issues = stmt
773            .query_map([issue_id], issue_from_row)?
774            .collect::<std::result::Result<Vec<_>, _>>()?;
775
776        Ok(issues)
777    }
778
779    // Milestones
780    pub fn create_milestone(&self, name: &str, description: Option<&str>) -> Result<i64> {
781        let now = Utc::now().to_rfc3339();
782        self.conn.execute(
783            "INSERT INTO milestones (name, description, status, created_at) VALUES (?1, ?2, 'open', ?3)",
784            params![name, description, now],
785        )?;
786        Ok(self.conn.last_insert_rowid())
787    }
788
789    pub fn get_milestone(&self, id: i64) -> Result<Option<crate::models::Milestone>> {
790        let mut stmt = self.conn.prepare(
791            "SELECT id, name, description, status, created_at, closed_at FROM milestones WHERE id = ?1",
792        )?;
793
794        let milestone = stmt
795            .query_row([id], |row| {
796                Ok(crate::models::Milestone {
797                    id: row.get(0)?,
798                    name: row.get(1)?,
799                    description: row.get(2)?,
800                    status: row.get(3)?,
801                    created_at: parse_datetime(row.get::<_, String>(4)?),
802                    closed_at: row.get::<_, Option<String>>(5)?.map(parse_datetime),
803                })
804            })
805            .ok();
806
807        Ok(milestone)
808    }
809
810    pub fn list_milestones(&self, status: Option<&str>) -> Result<Vec<crate::models::Milestone>> {
811        let (sql, params_vec): (&str, Vec<Box<dyn rusqlite::ToSql>>) = if let Some(s) = status {
812            if s == "all" {
813                ("SELECT id, name, description, status, created_at, closed_at FROM milestones ORDER BY id DESC", vec![])
814            } else {
815                ("SELECT id, name, description, status, created_at, closed_at FROM milestones WHERE status = ?1 ORDER BY id DESC",
816                 vec![Box::new(s.to_string())])
817            }
818        } else {
819            ("SELECT id, name, description, status, created_at, closed_at FROM milestones WHERE status = ?1 ORDER BY id DESC",
820             vec![Box::new("open".to_string())])
821        };
822
823        let params_refs: Vec<&dyn rusqlite::ToSql> =
824            params_vec.iter().map(|p| p.as_ref()).collect();
825        let mut stmt = self.conn.prepare(sql)?;
826        let milestones = stmt
827            .query_map(params_refs.as_slice(), |row| {
828                Ok(crate::models::Milestone {
829                    id: row.get(0)?,
830                    name: row.get(1)?,
831                    description: row.get(2)?,
832                    status: row.get(3)?,
833                    created_at: parse_datetime(row.get::<_, String>(4)?),
834                    closed_at: row.get::<_, Option<String>>(5)?.map(parse_datetime),
835                })
836            })?
837            .collect::<std::result::Result<Vec<_>, _>>()?;
838
839        Ok(milestones)
840    }
841
842    pub fn add_issue_to_milestone(&self, milestone_id: i64, issue_id: i64) -> Result<bool> {
843        let result = self.conn.execute(
844            "INSERT OR IGNORE INTO milestone_issues (milestone_id, issue_id) VALUES (?1, ?2)",
845            params![milestone_id, issue_id],
846        )?;
847        Ok(result > 0)
848    }
849
850    pub fn remove_issue_from_milestone(&self, milestone_id: i64, issue_id: i64) -> Result<bool> {
851        let rows = self.conn.execute(
852            "DELETE FROM milestone_issues WHERE milestone_id = ?1 AND issue_id = ?2",
853            params![milestone_id, issue_id],
854        )?;
855        Ok(rows > 0)
856    }
857
858    pub fn get_milestone_issues(&self, milestone_id: i64) -> Result<Vec<Issue>> {
859        let mut stmt = self.conn.prepare(
860            r#"
861            SELECT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
862            FROM issues i
863            JOIN milestone_issues mi ON i.id = mi.issue_id
864            WHERE mi.milestone_id = ?1
865            ORDER BY i.id
866            "#,
867        )?;
868
869        let issues = stmt
870            .query_map([milestone_id], issue_from_row)?
871            .collect::<std::result::Result<Vec<_>, _>>()?;
872
873        Ok(issues)
874    }
875
876    pub fn close_milestone(&self, id: i64) -> Result<bool> {
877        let now = Utc::now().to_rfc3339();
878        let rows = self.conn.execute(
879            "UPDATE milestones SET status = 'closed', closed_at = ?1 WHERE id = ?2",
880            params![now, id],
881        )?;
882        Ok(rows > 0)
883    }
884
885    pub fn delete_milestone(&self, id: i64) -> Result<bool> {
886        let rows = self
887            .conn
888            .execute("DELETE FROM milestones WHERE id = ?1", [id])?;
889        Ok(rows > 0)
890    }
891
892    pub fn get_issue_milestone(&self, issue_id: i64) -> Result<Option<crate::models::Milestone>> {
893        let mut stmt = self.conn.prepare(
894            r#"
895            SELECT m.id, m.name, m.description, m.status, m.created_at, m.closed_at
896            FROM milestones m
897            JOIN milestone_issues mi ON m.id = mi.milestone_id
898            WHERE mi.issue_id = ?1
899            LIMIT 1
900            "#,
901        )?;
902
903        let milestone = stmt
904            .query_row([issue_id], |row| {
905                Ok(crate::models::Milestone {
906                    id: row.get(0)?,
907                    name: row.get(1)?,
908                    description: row.get(2)?,
909                    status: row.get(3)?,
910                    created_at: parse_datetime(row.get::<_, String>(4)?),
911                    closed_at: row.get::<_, Option<String>>(5)?.map(parse_datetime),
912                })
913            })
914            .ok();
915
916        Ok(milestone)
917    }
918
919    // Archiving
920    pub fn archive_issue(&self, id: i64) -> Result<bool> {
921        let now = Utc::now().to_rfc3339();
922        let rows = self.conn.execute(
923            "UPDATE issues SET status = 'archived', updated_at = ?1 WHERE id = ?2 AND status = 'closed'",
924            params![now, id],
925        )?;
926        Ok(rows > 0)
927    }
928
929    pub fn unarchive_issue(&self, id: i64) -> Result<bool> {
930        let now = Utc::now().to_rfc3339();
931        let rows = self.conn.execute(
932            "UPDATE issues SET status = 'closed', updated_at = ?1 WHERE id = ?2 AND status = 'archived'",
933            params![now, id],
934        )?;
935        Ok(rows > 0)
936    }
937
938    pub fn list_archived_issues(&self) -> Result<Vec<Issue>> {
939        let mut stmt = self.conn.prepare(
940            "SELECT id, title, description, status, priority, parent_id, created_at, updated_at, closed_at FROM issues WHERE status = 'archived' ORDER BY id DESC",
941        )?;
942
943        let issues = stmt
944            .query_map([], issue_from_row)?
945            .collect::<std::result::Result<Vec<_>, _>>()?;
946
947        Ok(issues)
948    }
949
950    pub fn archive_older_than(&self, days: i64) -> Result<i32> {
951        let cutoff = Utc::now() - chrono::Duration::days(days);
952        let cutoff_str = cutoff.to_rfc3339();
953        let now = Utc::now().to_rfc3339();
954
955        let rows = self.conn.execute(
956            "UPDATE issues SET status = 'archived', updated_at = ?1 WHERE status = 'closed' AND closed_at < ?2",
957            params![now, cutoff_str],
958        )?;
959
960        Ok(rows as i32)
961    }
962}
963
964fn parse_datetime(s: String) -> DateTime<Utc> {
965    DateTime::parse_from_rfc3339(&s)
966        .map(|dt| dt.with_timezone(&Utc))
967        .unwrap_or_else(|_| Utc::now())
968}
969
970/// Maps a database row to an Issue struct.
971/// Expects columns in order: id, title, description, status, priority, parent_id, created_at, updated_at, closed_at
972fn issue_from_row(row: &rusqlite::Row) -> rusqlite::Result<Issue> {
973    Ok(Issue {
974        id: row.get(0)?,
975        title: row.get(1)?,
976        description: row.get(2)?,
977        status: row.get(3)?,
978        priority: row.get(4)?,
979        parent_id: row.get(5)?,
980        created_at: parse_datetime(row.get::<_, String>(6)?),
981        updated_at: parse_datetime(row.get::<_, String>(7)?),
982        closed_at: row.get::<_, Option<String>>(8)?.map(parse_datetime),
983    })
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use tempfile::tempdir;
990
991    fn setup_test_db() -> (Database, tempfile::TempDir) {
992        let dir = tempdir().unwrap();
993        let db_path = dir.path().join("test.db");
994        let db = Database::open(&db_path).unwrap();
995        (db, dir)
996    }
997
998    // ==================== Issue CRUD Tests ====================
999
1000    #[test]
1001    fn test_create_and_get_issue() {
1002        let (db, _dir) = setup_test_db();
1003
1004        let id = db.create_issue("Test issue", None, "medium").unwrap();
1005        assert!(id > 0);
1006
1007        let issue = db.get_issue(id).unwrap().unwrap();
1008        assert_eq!(issue.id, id);
1009        assert_eq!(issue.title, "Test issue");
1010        assert_eq!(issue.description, None);
1011        assert_eq!(issue.status, "open");
1012        assert_eq!(issue.priority, "medium");
1013        assert_eq!(issue.parent_id, None);
1014        assert!(issue.closed_at.is_none());
1015    }
1016
1017    #[test]
1018    fn test_create_issue_with_description() {
1019        let (db, _dir) = setup_test_db();
1020
1021        let id = db
1022            .create_issue("Test issue", Some("Detailed description"), "high")
1023            .unwrap();
1024        let issue = db.get_issue(id).unwrap().unwrap();
1025
1026        assert_eq!(issue.title, "Test issue");
1027        assert_eq!(issue.description, Some("Detailed description".to_string()));
1028        assert_eq!(issue.priority, "high");
1029    }
1030
1031    #[test]
1032    fn test_create_subissue() {
1033        let (db, _dir) = setup_test_db();
1034
1035        let parent_id = db.create_issue("Parent issue", None, "high").unwrap();
1036        let child_id = db
1037            .create_subissue(parent_id, "Child issue", None, "medium")
1038            .unwrap();
1039
1040        let child = db.get_issue(child_id).unwrap().unwrap();
1041        assert_eq!(child.parent_id, Some(parent_id));
1042
1043        let subissues = db.get_subissues(parent_id).unwrap();
1044        assert_eq!(subissues.len(), 1);
1045        assert_eq!(subissues[0].id, child_id);
1046    }
1047
1048    #[test]
1049    fn test_get_nonexistent_issue() {
1050        let (db, _dir) = setup_test_db();
1051        let issue = db.get_issue(99999).unwrap();
1052        assert!(issue.is_none());
1053    }
1054
1055    #[test]
1056    fn test_list_issues() {
1057        let (db, _dir) = setup_test_db();
1058
1059        db.create_issue("Issue 1", None, "low").unwrap();
1060        db.create_issue("Issue 2", None, "medium").unwrap();
1061        db.create_issue("Issue 3", None, "high").unwrap();
1062
1063        let issues = db.list_issues(None, None, None).unwrap();
1064        assert_eq!(issues.len(), 3);
1065    }
1066
1067    #[test]
1068    fn test_list_issues_filter_by_status() {
1069        let (db, _dir) = setup_test_db();
1070
1071        let id1 = db.create_issue("Open issue", None, "low").unwrap();
1072        let id2 = db.create_issue("To be closed", None, "medium").unwrap();
1073        db.close_issue(id2).unwrap();
1074
1075        let open_issues = db.list_issues(Some("open"), None, None).unwrap();
1076        assert_eq!(open_issues.len(), 1);
1077        assert_eq!(open_issues[0].id, id1);
1078
1079        let closed_issues = db.list_issues(Some("closed"), None, None).unwrap();
1080        assert_eq!(closed_issues.len(), 1);
1081        assert_eq!(closed_issues[0].id, id2);
1082
1083        let all_issues = db.list_issues(Some("all"), None, None).unwrap();
1084        assert_eq!(all_issues.len(), 2);
1085    }
1086
1087    #[test]
1088    fn test_list_issues_filter_by_priority() {
1089        let (db, _dir) = setup_test_db();
1090
1091        db.create_issue("Low priority", None, "low").unwrap();
1092        db.create_issue("High priority", None, "high").unwrap();
1093
1094        let high_issues = db.list_issues(None, None, Some("high")).unwrap();
1095        assert_eq!(high_issues.len(), 1);
1096        assert_eq!(high_issues[0].priority, "high");
1097    }
1098
1099    #[test]
1100    fn test_update_issue() {
1101        let (db, _dir) = setup_test_db();
1102
1103        let id = db.create_issue("Original title", None, "low").unwrap();
1104
1105        let updated = db
1106            .update_issue(
1107                id,
1108                Some("Updated title"),
1109                Some("New description"),
1110                Some("critical"),
1111            )
1112            .unwrap();
1113        assert!(updated);
1114
1115        let issue = db.get_issue(id).unwrap().unwrap();
1116        assert_eq!(issue.title, "Updated title");
1117        assert_eq!(issue.description, Some("New description".to_string()));
1118        assert_eq!(issue.priority, "critical");
1119    }
1120
1121    #[test]
1122    fn test_update_issue_partial() {
1123        let (db, _dir) = setup_test_db();
1124
1125        let id = db
1126            .create_issue("Original title", Some("Original desc"), "low")
1127            .unwrap();
1128
1129        db.update_issue(id, Some("New title"), None, None).unwrap();
1130
1131        let issue = db.get_issue(id).unwrap().unwrap();
1132        assert_eq!(issue.title, "New title");
1133        assert_eq!(issue.description, Some("Original desc".to_string()));
1134        assert_eq!(issue.priority, "low");
1135    }
1136
1137    #[test]
1138    fn test_close_and_reopen_issue() {
1139        let (db, _dir) = setup_test_db();
1140
1141        let id = db.create_issue("Test issue", None, "medium").unwrap();
1142
1143        let closed = db.close_issue(id).unwrap();
1144        assert!(closed);
1145
1146        let issue = db.get_issue(id).unwrap().unwrap();
1147        assert_eq!(issue.status, "closed");
1148        assert!(issue.closed_at.is_some());
1149
1150        let reopened = db.reopen_issue(id).unwrap();
1151        assert!(reopened);
1152
1153        let issue = db.get_issue(id).unwrap().unwrap();
1154        assert_eq!(issue.status, "open");
1155        assert!(issue.closed_at.is_none());
1156    }
1157
1158    #[test]
1159    fn test_close_nonexistent_issue_returns_false() {
1160        let (db, _dir) = setup_test_db();
1161
1162        // Closing an issue that doesn't exist should return false
1163        let closed = db.close_issue(99999).unwrap();
1164        assert!(
1165            !closed,
1166            "close_issue should return false for nonexistent issue"
1167        );
1168    }
1169
1170    #[test]
1171    fn test_reopen_nonexistent_issue_returns_false() {
1172        let (db, _dir) = setup_test_db();
1173
1174        // Reopening an issue that doesn't exist should return false
1175        let reopened = db.reopen_issue(99999).unwrap();
1176        assert!(
1177            !reopened,
1178            "reopen_issue should return false for nonexistent issue"
1179        );
1180    }
1181
1182    #[test]
1183    fn test_delete_issue() {
1184        let (db, _dir) = setup_test_db();
1185
1186        let id = db.create_issue("To delete", None, "low").unwrap();
1187        assert!(db.get_issue(id).unwrap().is_some());
1188
1189        let deleted = db.delete_issue(id).unwrap();
1190        assert!(deleted);
1191        assert!(db.get_issue(id).unwrap().is_none());
1192    }
1193
1194    #[test]
1195    fn test_delete_nonexistent_issue() {
1196        let (db, _dir) = setup_test_db();
1197        let deleted = db.delete_issue(99999).unwrap();
1198        assert!(!deleted);
1199    }
1200
1201    // ==================== Labels Tests ====================
1202
1203    #[test]
1204    fn test_add_and_get_labels() {
1205        let (db, _dir) = setup_test_db();
1206
1207        let id = db.create_issue("Test issue", None, "medium").unwrap();
1208
1209        db.add_label(id, "bug").unwrap();
1210        db.add_label(id, "urgent").unwrap();
1211
1212        let labels = db.get_labels(id).unwrap();
1213        assert_eq!(labels.len(), 2);
1214        assert!(labels.contains(&"bug".to_string()));
1215        assert!(labels.contains(&"urgent".to_string()));
1216    }
1217
1218    #[test]
1219    fn test_add_duplicate_label_returns_false() {
1220        let (db, _dir) = setup_test_db();
1221
1222        let id = db.create_issue("Test issue", None, "medium").unwrap();
1223
1224        // First add should return true (label was added)
1225        let first = db.add_label(id, "bug").unwrap();
1226        assert!(first, "First add_label should return true");
1227
1228        // Second add should return false (duplicate, nothing inserted)
1229        let second = db.add_label(id, "bug").unwrap();
1230        assert!(!second, "Duplicate add_label should return false");
1231
1232        let labels = db.get_labels(id).unwrap();
1233        assert_eq!(labels.len(), 1);
1234    }
1235
1236    #[test]
1237    fn test_remove_label() {
1238        let (db, _dir) = setup_test_db();
1239
1240        let id = db.create_issue("Test issue", None, "medium").unwrap();
1241
1242        db.add_label(id, "bug").unwrap();
1243        db.add_label(id, "urgent").unwrap();
1244
1245        let removed = db.remove_label(id, "bug").unwrap();
1246        assert!(removed);
1247
1248        let labels = db.get_labels(id).unwrap();
1249        assert_eq!(labels.len(), 1);
1250        assert_eq!(labels[0], "urgent");
1251    }
1252
1253    #[test]
1254    fn test_remove_nonexistent_label_returns_false() {
1255        let (db, _dir) = setup_test_db();
1256
1257        let id = db.create_issue("Test issue", None, "medium").unwrap();
1258        db.add_label(id, "bug").unwrap();
1259
1260        // Removing a label that doesn't exist should return false
1261        let removed = db.remove_label(id, "nonexistent").unwrap();
1262        assert!(
1263            !removed,
1264            "remove_label should return false for nonexistent label"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_list_issues_filter_by_label() {
1270        let (db, _dir) = setup_test_db();
1271
1272        let id1 = db.create_issue("Bug issue", None, "high").unwrap();
1273        let id2 = db.create_issue("Feature issue", None, "medium").unwrap();
1274
1275        db.add_label(id1, "bug").unwrap();
1276        db.add_label(id2, "feature").unwrap();
1277
1278        let bug_issues = db.list_issues(None, Some("bug"), None).unwrap();
1279        assert_eq!(bug_issues.len(), 1);
1280        assert_eq!(bug_issues[0].id, id1);
1281    }
1282
1283    // ==================== Comments Tests ====================
1284
1285    #[test]
1286    fn test_add_and_get_comments() {
1287        let (db, _dir) = setup_test_db();
1288
1289        let id = db.create_issue("Test issue", None, "medium").unwrap();
1290
1291        let comment_id = db.add_comment(id, "First comment").unwrap();
1292        assert!(comment_id > 0);
1293
1294        db.add_comment(id, "Second comment").unwrap();
1295
1296        let comments = db.get_comments(id).unwrap();
1297        assert_eq!(comments.len(), 2);
1298        assert_eq!(comments[0].content, "First comment");
1299        assert_eq!(comments[1].content, "Second comment");
1300    }
1301
1302    // ==================== Dependencies Tests ====================
1303
1304    #[test]
1305    fn test_add_and_get_dependencies() {
1306        let (db, _dir) = setup_test_db();
1307
1308        let blocker = db.create_issue("Blocker issue", None, "high").unwrap();
1309        let blocked = db.create_issue("Blocked issue", None, "medium").unwrap();
1310
1311        db.add_dependency(blocked, blocker).unwrap();
1312
1313        let blockers = db.get_blockers(blocked).unwrap();
1314        assert_eq!(blockers.len(), 1);
1315        assert_eq!(blockers[0], blocker);
1316
1317        let blocking = db.get_blocking(blocker).unwrap();
1318        assert_eq!(blocking.len(), 1);
1319        assert_eq!(blocking[0], blocked);
1320    }
1321
1322    #[test]
1323    fn test_remove_dependency() {
1324        let (db, _dir) = setup_test_db();
1325
1326        let blocker = db.create_issue("Blocker", None, "high").unwrap();
1327        let blocked = db.create_issue("Blocked", None, "medium").unwrap();
1328
1329        db.add_dependency(blocked, blocker).unwrap();
1330        let removed = db.remove_dependency(blocked, blocker).unwrap();
1331        assert!(removed);
1332
1333        let blockers = db.get_blockers(blocked).unwrap();
1334        assert!(blockers.is_empty());
1335    }
1336
1337    #[test]
1338    fn test_list_blocked_issues() {
1339        let (db, _dir) = setup_test_db();
1340
1341        let blocker = db.create_issue("Blocker", None, "high").unwrap();
1342        let blocked = db.create_issue("Blocked", None, "medium").unwrap();
1343        let unblocked = db.create_issue("Unblocked", None, "low").unwrap();
1344
1345        db.add_dependency(blocked, blocker).unwrap();
1346
1347        let blocked_issues = db.list_blocked_issues().unwrap();
1348        assert_eq!(blocked_issues.len(), 1);
1349        assert_eq!(blocked_issues[0].id, blocked);
1350
1351        // Unblocked issue should not appear
1352        assert!(!blocked_issues.iter().any(|i| i.id == unblocked));
1353    }
1354
1355    #[test]
1356    fn test_list_ready_issues() {
1357        let (db, _dir) = setup_test_db();
1358
1359        let blocker = db.create_issue("Blocker", None, "high").unwrap();
1360        let blocked = db.create_issue("Blocked", None, "medium").unwrap();
1361        let ready = db.create_issue("Ready", None, "low").unwrap();
1362
1363        db.add_dependency(blocked, blocker).unwrap();
1364
1365        let ready_issues = db.list_ready_issues().unwrap();
1366
1367        // Blocker and ready should be in ready list (not blocked by anything)
1368        let ready_ids: Vec<i64> = ready_issues.iter().map(|i| i.id).collect();
1369        assert!(ready_ids.contains(&blocker));
1370        assert!(ready_ids.contains(&ready));
1371        assert!(!ready_ids.contains(&blocked));
1372    }
1373
1374    #[test]
1375    fn test_blocked_becomes_ready_when_blocker_closed() {
1376        let (db, _dir) = setup_test_db();
1377
1378        let blocker = db.create_issue("Blocker", None, "high").unwrap();
1379        let blocked = db.create_issue("Blocked", None, "medium").unwrap();
1380
1381        db.add_dependency(blocked, blocker).unwrap();
1382
1383        // Initially blocked
1384        let blocked_issues = db.list_blocked_issues().unwrap();
1385        assert_eq!(blocked_issues.len(), 1);
1386
1387        // Close blocker
1388        db.close_issue(blocker).unwrap();
1389
1390        // Now should be ready
1391        let blocked_issues = db.list_blocked_issues().unwrap();
1392        assert!(blocked_issues.is_empty());
1393
1394        let ready_issues = db.list_ready_issues().unwrap();
1395        assert!(ready_issues.iter().any(|i| i.id == blocked));
1396    }
1397
1398    // ==================== Sessions Tests ====================
1399
1400    #[test]
1401    fn test_start_and_get_session() {
1402        let (db, _dir) = setup_test_db();
1403
1404        let id = db.start_session().unwrap();
1405        assert!(id > 0);
1406
1407        let session = db.get_current_session().unwrap().unwrap();
1408        assert_eq!(session.id, id);
1409        assert!(session.ended_at.is_none());
1410        assert!(session.active_issue_id.is_none());
1411    }
1412
1413    #[test]
1414    fn test_end_session() {
1415        let (db, _dir) = setup_test_db();
1416
1417        let id = db.start_session().unwrap();
1418        db.end_session(id, Some("Handoff notes")).unwrap();
1419
1420        let current = db.get_current_session().unwrap();
1421        assert!(current.is_none());
1422
1423        let last = db.get_last_session().unwrap().unwrap();
1424        assert_eq!(last.id, id);
1425        assert!(last.ended_at.is_some());
1426        assert_eq!(last.handoff_notes, Some("Handoff notes".to_string()));
1427    }
1428
1429    #[test]
1430    fn test_set_session_issue() {
1431        let (db, _dir) = setup_test_db();
1432
1433        let issue_id = db.create_issue("Test issue", None, "medium").unwrap();
1434        let session_id = db.start_session().unwrap();
1435
1436        db.set_session_issue(session_id, issue_id).unwrap();
1437
1438        let session = db.get_current_session().unwrap().unwrap();
1439        assert_eq!(session.active_issue_id, Some(issue_id));
1440    }
1441
1442    // ==================== Time Tracking Tests ====================
1443
1444    #[test]
1445    fn test_start_and_stop_timer() {
1446        let (db, _dir) = setup_test_db();
1447
1448        let id = db.create_issue("Test issue", None, "medium").unwrap();
1449
1450        let timer_id = db.start_timer(id).unwrap();
1451        assert!(timer_id > 0);
1452
1453        let active = db.get_active_timer().unwrap();
1454        assert!(active.is_some());
1455        assert_eq!(active.unwrap().0, id);
1456
1457        std::thread::sleep(std::time::Duration::from_millis(100));
1458
1459        db.stop_timer(id).unwrap();
1460
1461        let active = db.get_active_timer().unwrap();
1462        assert!(active.is_none());
1463    }
1464
1465    #[test]
1466    fn test_get_total_time() {
1467        let (db, _dir) = setup_test_db();
1468
1469        let id = db.create_issue("Test issue", None, "medium").unwrap();
1470
1471        // No time tracked yet
1472        let total = db.get_total_time(id).unwrap();
1473        assert_eq!(total, 0);
1474    }
1475
1476    // ==================== Search Tests ====================
1477
1478    #[test]
1479    fn test_search_issues_by_title() {
1480        let (db, _dir) = setup_test_db();
1481
1482        db.create_issue("Fix authentication bug", None, "high")
1483            .unwrap();
1484        db.create_issue("Add dark mode", None, "medium").unwrap();
1485        db.create_issue("Auth improvements", None, "low").unwrap();
1486
1487        let results = db.search_issues("auth").unwrap();
1488        assert_eq!(results.len(), 2);
1489    }
1490
1491    #[test]
1492    fn test_search_issues_by_description() {
1493        let (db, _dir) = setup_test_db();
1494
1495        db.create_issue(
1496            "Feature A",
1497            Some("This relates to authentication"),
1498            "medium",
1499        )
1500        .unwrap();
1501        db.create_issue("Feature B", Some("Something else"), "medium")
1502            .unwrap();
1503
1504        let results = db.search_issues("authentication").unwrap();
1505        assert_eq!(results.len(), 1);
1506    }
1507
1508    #[test]
1509    fn test_search_issues_by_comment() {
1510        let (db, _dir) = setup_test_db();
1511
1512        let id = db.create_issue("Some issue", None, "medium").unwrap();
1513        db.add_comment(id, "Found the root cause in authentication module")
1514            .unwrap();
1515
1516        let results = db.search_issues("authentication").unwrap();
1517        assert_eq!(results.len(), 1);
1518        assert_eq!(results[0].id, id);
1519    }
1520
1521    // ==================== Relations Tests ====================
1522
1523    #[test]
1524    fn test_add_and_get_relations() {
1525        let (db, _dir) = setup_test_db();
1526
1527        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
1528        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();
1529
1530        db.add_relation(id1, id2).unwrap();
1531
1532        let related = db.get_related_issues(id1).unwrap();
1533        assert_eq!(related.len(), 1);
1534        assert_eq!(related[0].id, id2);
1535
1536        // Bidirectional
1537        let related = db.get_related_issues(id2).unwrap();
1538        assert_eq!(related.len(), 1);
1539        assert_eq!(related[0].id, id1);
1540    }
1541
1542    #[test]
1543    fn test_relation_to_self_fails() {
1544        let (db, _dir) = setup_test_db();
1545
1546        let id = db.create_issue("Issue", None, "medium").unwrap();
1547
1548        let result = db.add_relation(id, id);
1549        assert!(result.is_err());
1550    }
1551
1552    #[test]
1553    fn test_remove_relation() {
1554        let (db, _dir) = setup_test_db();
1555
1556        let id1 = db.create_issue("Issue 1", None, "medium").unwrap();
1557        let id2 = db.create_issue("Issue 2", None, "medium").unwrap();
1558
1559        db.add_relation(id1, id2).unwrap();
1560        db.remove_relation(id1, id2).unwrap();
1561
1562        let related = db.get_related_issues(id1).unwrap();
1563        assert!(related.is_empty());
1564    }
1565
1566    // ==================== Milestones Tests ====================
1567
1568    #[test]
1569    fn test_create_and_get_milestone() {
1570        let (db, _dir) = setup_test_db();
1571
1572        let id = db.create_milestone("v1.0", Some("First release")).unwrap();
1573        assert!(id > 0);
1574
1575        let milestone = db.get_milestone(id).unwrap().unwrap();
1576        assert_eq!(milestone.name, "v1.0");
1577        assert_eq!(milestone.description, Some("First release".to_string()));
1578        assert_eq!(milestone.status, "open");
1579    }
1580
1581    #[test]
1582    fn test_list_milestones() {
1583        let (db, _dir) = setup_test_db();
1584
1585        db.create_milestone("v1.0", None).unwrap();
1586        db.create_milestone("v2.0", None).unwrap();
1587
1588        let milestones = db.list_milestones(None).unwrap();
1589        assert_eq!(milestones.len(), 2);
1590    }
1591
1592    #[test]
1593    fn test_add_issue_to_milestone() {
1594        let (db, _dir) = setup_test_db();
1595
1596        let milestone_id = db.create_milestone("v1.0", None).unwrap();
1597        let issue_id = db.create_issue("Feature", None, "medium").unwrap();
1598
1599        db.add_issue_to_milestone(milestone_id, issue_id).unwrap();
1600
1601        let issues = db.get_milestone_issues(milestone_id).unwrap();
1602        assert_eq!(issues.len(), 1);
1603        assert_eq!(issues[0].id, issue_id);
1604
1605        let milestone = db.get_issue_milestone(issue_id).unwrap().unwrap();
1606        assert_eq!(milestone.id, milestone_id);
1607    }
1608
1609    #[test]
1610    fn test_close_milestone() {
1611        let (db, _dir) = setup_test_db();
1612
1613        let id = db.create_milestone("v1.0", None).unwrap();
1614        db.close_milestone(id).unwrap();
1615
1616        let milestone = db.get_milestone(id).unwrap().unwrap();
1617        assert_eq!(milestone.status, "closed");
1618        assert!(milestone.closed_at.is_some());
1619    }
1620
1621    // ==================== Archive Tests ====================
1622
1623    #[test]
1624    fn test_archive_closed_issue() {
1625        let (db, _dir) = setup_test_db();
1626
1627        let id = db.create_issue("Test", None, "medium").unwrap();
1628        db.close_issue(id).unwrap();
1629
1630        let archived = db.archive_issue(id).unwrap();
1631        assert!(archived);
1632
1633        let issue = db.get_issue(id).unwrap().unwrap();
1634        assert_eq!(issue.status, "archived");
1635    }
1636
1637    #[test]
1638    fn test_archive_open_issue_fails() {
1639        let (db, _dir) = setup_test_db();
1640
1641        let id = db.create_issue("Test", None, "medium").unwrap();
1642
1643        let archived = db.archive_issue(id).unwrap();
1644        assert!(!archived);
1645
1646        let issue = db.get_issue(id).unwrap().unwrap();
1647        assert_eq!(issue.status, "open");
1648    }
1649
1650    #[test]
1651    fn test_unarchive_issue() {
1652        let (db, _dir) = setup_test_db();
1653
1654        let id = db.create_issue("Test", None, "medium").unwrap();
1655        db.close_issue(id).unwrap();
1656        db.archive_issue(id).unwrap();
1657
1658        let unarchived = db.unarchive_issue(id).unwrap();
1659        assert!(unarchived);
1660
1661        let issue = db.get_issue(id).unwrap().unwrap();
1662        assert_eq!(issue.status, "closed");
1663    }
1664
1665    #[test]
1666    fn test_list_archived_issues() {
1667        let (db, _dir) = setup_test_db();
1668
1669        let id1 = db.create_issue("Archived", None, "medium").unwrap();
1670        let _id2 = db.create_issue("Open", None, "medium").unwrap();
1671
1672        db.close_issue(id1).unwrap();
1673        db.archive_issue(id1).unwrap();
1674
1675        let archived = db.list_archived_issues().unwrap();
1676        assert_eq!(archived.len(), 1);
1677        assert_eq!(archived[0].id, id1);
1678    }
1679
1680    // ==================== Security Tests ====================
1681
1682    #[test]
1683    fn test_sql_injection_in_title() {
1684        let (db, _dir) = setup_test_db();
1685
1686        // Attempt SQL injection via title
1687        let malicious = "'; DROP TABLE issues; --";
1688        let id = db.create_issue(malicious, None, "medium").unwrap();
1689
1690        // Should have created issue with literal string, not executed SQL
1691        let issue = db.get_issue(id).unwrap().unwrap();
1692        assert_eq!(issue.title, malicious);
1693
1694        // Database should still be intact
1695        let issues = db.list_issues(None, None, None).unwrap();
1696        assert!(!issues.is_empty());
1697    }
1698
1699    #[test]
1700    fn test_sql_injection_in_description() {
1701        let (db, _dir) = setup_test_db();
1702
1703        let malicious = "test'); DELETE FROM issues; --";
1704        let id = db
1705            .create_issue("Normal title", Some(malicious), "medium")
1706            .unwrap();
1707
1708        let issue = db.get_issue(id).unwrap().unwrap();
1709        assert_eq!(issue.description, Some(malicious.to_string()));
1710    }
1711
1712    #[test]
1713    fn test_sql_injection_in_label() {
1714        let (db, _dir) = setup_test_db();
1715
1716        let id = db.create_issue("Test", None, "medium").unwrap();
1717        let malicious = "bug'; DROP TABLE labels; --";
1718
1719        db.add_label(id, malicious).unwrap();
1720
1721        let labels = db.get_labels(id).unwrap();
1722        assert_eq!(labels.len(), 1);
1723        assert_eq!(labels[0], malicious);
1724    }
1725
1726    #[test]
1727    fn test_sql_injection_in_search() {
1728        let (db, _dir) = setup_test_db();
1729
1730        db.create_issue("Normal issue", None, "medium").unwrap();
1731
1732        // Attempt injection in search
1733        let malicious = "%'; DROP TABLE issues; --";
1734        let results = db.search_issues(malicious).unwrap();
1735
1736        // Should return empty results, not crash
1737        assert!(results.is_empty());
1738
1739        // Database should still be intact
1740        let issues = db.list_issues(None, None, None).unwrap();
1741        assert!(!issues.is_empty());
1742    }
1743
1744    #[test]
1745    fn test_sql_injection_in_comment() {
1746        let (db, _dir) = setup_test_db();
1747
1748        let id = db.create_issue("Test", None, "medium").unwrap();
1749        let malicious = "comment'); DELETE FROM comments; --";
1750
1751        db.add_comment(id, malicious).unwrap();
1752
1753        let comments = db.get_comments(id).unwrap();
1754        assert_eq!(comments.len(), 1);
1755        assert_eq!(comments[0].content, malicious);
1756    }
1757
1758    #[test]
1759    fn test_unicode_in_fields() {
1760        let (db, _dir) = setup_test_db();
1761
1762        let title = "测试问题 🐛 αβγ";
1763        let description = "Description with émojis 🎉 and ñ";
1764
1765        let id = db.create_issue(title, Some(description), "medium").unwrap();
1766
1767        let issue = db.get_issue(id).unwrap().unwrap();
1768        assert_eq!(issue.title, title);
1769        assert_eq!(issue.description, Some(description.to_string()));
1770    }
1771
1772    #[test]
1773    fn test_very_long_strings() {
1774        let (db, _dir) = setup_test_db();
1775
1776        let long_title = "a".repeat(10000);
1777        let long_desc = "b".repeat(100000);
1778
1779        let id = db
1780            .create_issue(&long_title, Some(&long_desc), "medium")
1781            .unwrap();
1782
1783        let issue = db.get_issue(id).unwrap().unwrap();
1784        assert_eq!(issue.title.len(), 10000);
1785        assert_eq!(issue.description.unwrap().len(), 100000);
1786    }
1787
1788    #[test]
1789    fn test_null_bytes_in_strings() {
1790        let (db, _dir) = setup_test_db();
1791
1792        let title = "test\0null\0bytes";
1793        let id = db.create_issue(title, None, "medium").unwrap();
1794
1795        let issue = db.get_issue(id).unwrap().unwrap();
1796        assert_eq!(issue.title, title);
1797    }
1798
1799    // ==================== Cascade Delete Tests ====================
1800
1801    #[test]
1802    fn test_delete_issue_cascades_labels() {
1803        let (db, _dir) = setup_test_db();
1804
1805        let id = db.create_issue("Test", None, "medium").unwrap();
1806        db.add_label(id, "bug").unwrap();
1807        db.add_label(id, "urgent").unwrap();
1808
1809        db.delete_issue(id).unwrap();
1810
1811        // Labels should be gone (via CASCADE)
1812        let labels = db.get_labels(id).unwrap();
1813        assert!(labels.is_empty());
1814    }
1815
1816    #[test]
1817    fn test_delete_issue_cascades_comments() {
1818        let (db, _dir) = setup_test_db();
1819
1820        let id = db.create_issue("Test", None, "medium").unwrap();
1821        db.add_comment(id, "Comment 1").unwrap();
1822        db.add_comment(id, "Comment 2").unwrap();
1823
1824        db.delete_issue(id).unwrap();
1825
1826        let comments = db.get_comments(id).unwrap();
1827        assert!(comments.is_empty());
1828    }
1829
1830    #[test]
1831    fn test_delete_parent_cascades_subissues() {
1832        let (db, _dir) = setup_test_db();
1833
1834        let parent_id = db.create_issue("Parent", None, "high").unwrap();
1835        let child_id = db
1836            .create_subissue(parent_id, "Child", None, "medium")
1837            .unwrap();
1838
1839        db.delete_issue(parent_id).unwrap();
1840
1841        // Child should be deleted too
1842        assert!(db.get_issue(child_id).unwrap().is_none());
1843    }
1844
1845    // ==================== Edge Cases ====================
1846
1847    #[test]
1848    fn test_empty_title() {
1849        let (db, _dir) = setup_test_db();
1850
1851        let id = db.create_issue("", None, "medium").unwrap();
1852        let issue = db.get_issue(id).unwrap().unwrap();
1853        assert_eq!(issue.title, "");
1854    }
1855
1856    #[test]
1857    fn test_update_parent() {
1858        let (db, _dir) = setup_test_db();
1859
1860        let parent1 = db.create_issue("Parent 1", None, "high").unwrap();
1861        let parent2 = db.create_issue("Parent 2", None, "high").unwrap();
1862        let child = db.create_issue("Child", None, "medium").unwrap();
1863
1864        db.update_parent(child, Some(parent1)).unwrap();
1865        let issue = db.get_issue(child).unwrap().unwrap();
1866        assert_eq!(issue.parent_id, Some(parent1));
1867
1868        db.update_parent(child, Some(parent2)).unwrap();
1869        let issue = db.get_issue(child).unwrap().unwrap();
1870        assert_eq!(issue.parent_id, Some(parent2));
1871
1872        db.update_parent(child, None).unwrap();
1873        let issue = db.get_issue(child).unwrap().unwrap();
1874        assert_eq!(issue.parent_id, None);
1875    }
1876
1877    // ==================== Database Corruption Recovery ====================
1878
1879    #[test]
1880    fn test_corrupted_db_file_empty() {
1881        let dir = tempfile::tempdir().unwrap();
1882        let db_path = dir.path().join("issues.db");
1883
1884        // Create an empty file (corrupted)
1885        std::fs::write(&db_path, b"").unwrap();
1886
1887        // SQLite treats empty files as new databases, so this should succeed
1888        // and the database should be usable afterward
1889        let result = Database::open(&db_path);
1890        assert!(
1891            result.is_ok(),
1892            "Empty file should be treated as new DB: {:?}",
1893            result.err()
1894        );
1895        let db = result.unwrap();
1896        let id = db
1897            .create_issue("Test after recovery", None, "medium")
1898            .unwrap();
1899        assert!(id > 0);
1900    }
1901
1902    #[test]
1903    fn test_corrupted_db_file_garbage() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let db_path = dir.path().join("issues.db");
1906
1907        // Write garbage data
1908        std::fs::write(&db_path, b"not a sqlite database at all!").unwrap();
1909
1910        // Should fail gracefully with an error, not panic
1911        let result = Database::open(&db_path);
1912        assert!(result.is_err());
1913    }
1914
1915    #[test]
1916    fn test_corrupted_db_file_truncated() {
1917        let dir = tempfile::tempdir().unwrap();
1918        let db_path = dir.path().join("issues.db");
1919
1920        // Create valid DB first
1921        {
1922            let db = Database::open(&db_path).unwrap();
1923            db.create_issue("Test", None, "medium").unwrap();
1924        }
1925
1926        // Truncate it (simulate crash during write)
1927        let content = std::fs::read(&db_path).unwrap();
1928        std::fs::write(&db_path, &content[..content.len() / 2]).unwrap();
1929
1930        // Truncated DB should fail to open -- SQLite detects corruption
1931        let result = Database::open(&db_path);
1932        match result {
1933            Err(e) => {
1934                let err_msg = format!("{}", e);
1935                assert!(
1936                    err_msg.contains("not a database")
1937                        || err_msg.contains("malformed")
1938                        || err_msg.contains("corrupt")
1939                        || err_msg.contains("disk image"),
1940                    "Error should indicate corruption, got: {}",
1941                    err_msg
1942                );
1943            }
1944            Ok(db) => {
1945                // If SQLite somehow recovers, verify the original data is gone
1946                let issues = db.list_issues(Some("all"), None, None).unwrap();
1947                assert!(
1948                    issues.is_empty(),
1949                    "Truncated DB should not retain original data"
1950                );
1951            }
1952        }
1953    }
1954
1955    #[test]
1956    fn test_db_readonly_location() {
1957        // This test only works on Unix-like systems
1958        #[cfg(unix)]
1959        {
1960            use std::os::unix::fs::PermissionsExt;
1961
1962            let dir = tempfile::tempdir().unwrap();
1963            let db_path = dir.path().join("issues.db");
1964
1965            // Create the file first
1966            std::fs::write(&db_path, b"").unwrap();
1967
1968            // Make it read-only
1969            let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
1970            perms.set_mode(0o444);
1971            std::fs::set_permissions(&db_path, perms).unwrap();
1972
1973            // Should fail gracefully
1974            let result = Database::open(&db_path);
1975            assert!(result.is_err());
1976        }
1977    }
1978}
1979
1980// ==================== Property-Based Tests ====================
1981
1982#[cfg(test)]
1983mod proptest_tests {
1984    use super::*;
1985    use proptest::prelude::*;
1986
1987    fn setup_test_db() -> (Database, tempfile::TempDir) {
1988        let dir = tempfile::tempdir().unwrap();
1989        let db_path = dir.path().join("issues.db");
1990        let db = Database::open(&db_path).unwrap();
1991        (db, dir)
1992    }
1993
1994    // Generate valid priority strings
1995    fn valid_priority() -> impl Strategy<Value = String> {
1996        prop_oneof![
1997            Just("low".to_string()),
1998            Just("medium".to_string()),
1999            Just("high".to_string()),
2000            Just("critical".to_string()),
2001        ]
2002    }
2003
2004    // Generate arbitrary (but safe) strings for titles
2005    fn safe_string() -> impl Strategy<Value = String> {
2006        // Avoid null bytes and extremely long strings
2007        "[a-zA-Z0-9 _\\-\\.!?]{0,1000}".prop_map(|s| s)
2008    }
2009
2010    proptest! {
2011        /// Any valid title should be storable and retrievable unchanged
2012        #[test]
2013        fn prop_title_roundtrip(title in safe_string()) {
2014            let (db, _dir) = setup_test_db();
2015            let id = db.create_issue(&title, None, "medium").unwrap();
2016            let issue = db.get_issue(id).unwrap().unwrap();
2017            prop_assert_eq!(issue.title, title);
2018        }
2019
2020        /// Any valid description should be storable and retrievable unchanged
2021        #[test]
2022        fn prop_description_roundtrip(desc in safe_string()) {
2023            let (db, _dir) = setup_test_db();
2024            let id = db.create_issue("Test", Some(&desc), "medium").unwrap();
2025            let issue = db.get_issue(id).unwrap().unwrap();
2026            prop_assert_eq!(issue.description, Some(desc));
2027        }
2028
2029        /// All valid priorities should work
2030        #[test]
2031        fn prop_priority_valid(priority in valid_priority()) {
2032            let (db, _dir) = setup_test_db();
2033            let id = db.create_issue("Test", None, &priority).unwrap();
2034            let issue = db.get_issue(id).unwrap().unwrap();
2035            prop_assert_eq!(issue.priority, priority);
2036        }
2037
2038        /// Labels should be storable and retrievable
2039        #[test]
2040        fn prop_label_roundtrip(label in "[a-zA-Z0-9_\\-]{1,50}") {
2041            let (db, _dir) = setup_test_db();
2042            let id = db.create_issue("Test", None, "medium").unwrap();
2043            db.add_label(id, &label).unwrap();
2044            let labels = db.get_labels(id).unwrap();
2045            prop_assert!(labels.contains(&label));
2046        }
2047
2048        /// Comments should be storable and retrievable
2049        #[test]
2050        fn prop_comment_roundtrip(content in safe_string()) {
2051            let (db, _dir) = setup_test_db();
2052            let id = db.create_issue("Test", None, "medium").unwrap();
2053            db.add_comment(id, &content).unwrap();
2054            let comments = db.get_comments(id).unwrap();
2055            prop_assert_eq!(comments.len(), 1);
2056            prop_assert_eq!(&comments[0].content, &content);
2057        }
2058
2059        /// Creating multiple issues should always increase count
2060        #[test]
2061        fn prop_create_increases_count(count in 1usize..20) {
2062            let (db, _dir) = setup_test_db();
2063            for i in 0..count {
2064                db.create_issue(&format!("Issue {}", i), None, "medium").unwrap();
2065            }
2066            let issues = db.list_issues(None, None, None).unwrap();
2067            prop_assert_eq!(issues.len(), count);
2068        }
2069
2070        /// Close then reopen should leave issue open
2071        #[test]
2072        fn prop_close_reopen_idempotent(title in safe_string()) {
2073            let (db, _dir) = setup_test_db();
2074            let id = db.create_issue(&title, None, "medium").unwrap();
2075
2076            db.close_issue(id).unwrap();
2077            let issue = db.get_issue(id).unwrap().unwrap();
2078            prop_assert_eq!(issue.status, "closed");
2079
2080            db.reopen_issue(id).unwrap();
2081            let issue = db.get_issue(id).unwrap().unwrap();
2082            prop_assert_eq!(issue.status, "open");
2083        }
2084
2085        /// Blocking should be reflected in blocked list
2086        #[test]
2087        fn prop_blocking_relationship(a in 1i64..100, b in 1i64..100) {
2088            if a == b {
2089                return Ok(()); // Skip self-blocking
2090            }
2091            let (db, _dir) = setup_test_db();
2092
2093            // Create both issues
2094            for i in 1..=std::cmp::max(a, b) {
2095                db.create_issue(&format!("Issue {}", i), None, "medium").unwrap();
2096            }
2097
2098            db.add_dependency(a, b).unwrap();
2099            let blockers = db.get_blockers(a).unwrap();
2100            prop_assert!(blockers.contains(&b));
2101        }
2102
2103        /// Search should find issues with matching titles
2104        #[test]
2105        fn prop_search_finds_title(
2106            prefix in "[a-zA-Z]{3,10}",
2107            suffix in "[a-zA-Z]{3,10}"
2108        ) {
2109            let (db, _dir) = setup_test_db();
2110            let title = format!("{} unique marker {}", prefix, suffix);
2111            db.create_issue(&title, None, "medium").unwrap();
2112
2113            // Search for the unique marker
2114            let results = db.search_issues("unique marker").unwrap();
2115            prop_assert!(!results.is_empty());
2116            prop_assert!(results.iter().any(|i| i.title.contains("unique marker")));
2117        }
2118
2119        /// Circular dependencies should be prevented
2120        #[test]
2121        fn prop_no_circular_deps(chain_len in 2usize..6) {
2122            let (db, _dir) = setup_test_db();
2123
2124            // Create a chain of issues
2125            let mut ids = Vec::new();
2126            for i in 0..chain_len {
2127                let id = db.create_issue(&format!("Issue {}", i), None, "medium").unwrap();
2128                ids.push(id);
2129            }
2130
2131            // Create a linear dependency chain: 0 <- 1 <- 2 <- ... <- n-1
2132            for i in 0..chain_len - 1 {
2133                db.add_dependency(ids[i], ids[i + 1]).unwrap();
2134            }
2135
2136            // Trying to close the cycle (n-1 <- 0) should fail
2137            let result = db.add_dependency(ids[chain_len - 1], ids[0]);
2138            prop_assert!(result.is_err(), "Circular dependency should be rejected");
2139        }
2140
2141        /// Deleting a parent should cascade to all children
2142        #[test]
2143        fn prop_cascade_deletes_children(child_count in 1usize..5) {
2144            let (db, _dir) = setup_test_db();
2145
2146            // Create parent
2147            let parent_id = db.create_issue("Parent", None, "medium").unwrap();
2148
2149            // Create children
2150            let mut child_ids = Vec::new();
2151            for i in 0..child_count {
2152                let id = db.create_subissue(parent_id, &format!("Child {}", i), None, "low").unwrap();
2153                child_ids.push(id);
2154            }
2155
2156            // Verify children exist
2157            let issues_before = db.list_issues(None, None, None).unwrap();
2158            prop_assert_eq!(issues_before.len(), child_count + 1);
2159
2160            // Delete parent
2161            db.delete_issue(parent_id).unwrap();
2162
2163            // All children should be gone too
2164            let issues_after = db.list_issues(None, None, None).unwrap();
2165            prop_assert_eq!(issues_after.len(), 0);
2166
2167            // Verify each child is gone
2168            for child_id in child_ids {
2169                let child = db.get_issue(child_id).unwrap();
2170                prop_assert!(child.is_none(), "Child should be deleted");
2171            }
2172        }
2173
2174        /// Ready list should never contain issues with open blockers
2175        #[test]
2176        fn prop_ready_list_correctness(issue_count in 2usize..8) {
2177            let (db, _dir) = setup_test_db();
2178
2179            // Create issues
2180            let mut ids = Vec::new();
2181            for i in 0..issue_count {
2182                let id = db.create_issue(&format!("Issue {}", i), None, "medium").unwrap();
2183                ids.push(id);
2184            }
2185
2186            // Create some dependencies (each issue blocked by next, except last)
2187            for i in 0..issue_count - 1 {
2188                let _ = db.add_dependency(ids[i], ids[i + 1]);
2189            }
2190
2191            // Get ready issues
2192            let ready = db.list_ready_issues().unwrap();
2193
2194            // Verify: no ready issue should have open blockers
2195            for issue in &ready {
2196                let blockers = db.get_blockers(issue.id).unwrap();
2197                for blocker_id in blockers {
2198                    if let Some(blocker) = db.get_issue(blocker_id).unwrap() {
2199                        prop_assert_ne!(
2200                            blocker.status, "open",
2201                            "Ready issue {} has open blocker {}",
2202                            issue.id, blocker_id
2203                        );
2204                    }
2205                }
2206            }
2207        }
2208
2209        /// Session active_issue_id should be set to NULL when issue is deleted
2210        #[test]
2211        fn prop_session_issue_delete_cascade(title in safe_string()) {
2212            let (db, _dir) = setup_test_db();
2213
2214            // Create issue and session
2215            let issue_id = db.create_issue(&title, None, "medium").unwrap();
2216            let session_id = db.start_session().unwrap();
2217            db.set_session_issue(session_id, issue_id).unwrap();
2218
2219            // Verify session has issue
2220            let session = db.get_current_session().unwrap().unwrap();
2221            prop_assert_eq!(session.active_issue_id, Some(issue_id));
2222
2223            // Delete the issue
2224            db.delete_issue(issue_id).unwrap();
2225
2226            // Session should still exist but with NULL active_issue_id
2227            let session_after = db.get_current_session().unwrap().unwrap();
2228            prop_assert_eq!(session_after.id, session_id);
2229            prop_assert_eq!(session_after.active_issue_id, None, "Session active_issue_id should be NULL after issue deletion");
2230        }
2231
2232        /// Search wildcards should be escaped properly
2233        #[test]
2234        fn prop_search_wildcards_escaped(
2235            prefix in "[a-zA-Z]{3,5}",
2236            suffix in "[a-zA-Z]{3,5}"
2237        ) {
2238            let (db, _dir) = setup_test_db();
2239
2240            // Create an issue with % and _ in title
2241            let special_title = format!("{}%test_marker{}", prefix, suffix);
2242            db.create_issue(&special_title, None, "medium").unwrap();
2243
2244            // Create another issue that would match if wildcards weren't escaped
2245            db.create_issue("other content here", None, "medium").unwrap();
2246
2247            // Search for the special characters literally
2248            let results = db.search_issues("%test_").unwrap();
2249
2250            // Should find only the issue with literal % and _
2251            prop_assert!(results.iter().all(|i| i.title.contains("%test_")));
2252        }
2253    }
2254}