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 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 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 let _ = self.conn.execute(
163 "ALTER TABLE issues ADD COLUMN parent_id INTEGER REFERENCES issues(id) ON DELETE CASCADE",
164 [],
165 );
166
167 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 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 self.conn.execute("PRAGMA foreign_keys = ON", [])?;
200
201 Ok(())
202 }
203
204 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 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 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 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 pub fn add_dependency(&self, blocked_id: i64, blocker_id: i64) -> Result<bool> {
439 if blocked_id == blocker_id {
441 anyhow::bail!("An issue cannot block itself");
442 }
443
444 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 fn would_create_cycle(&self, blocked_id: i64, blocker_id: i64) -> Result<bool> {
459 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); }
467
468 if visited.insert(current) {
469 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 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 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 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 pub fn search_issues(&self, query: &str) -> Result<Vec<Issue>> {
695 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 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 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 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 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
970fn 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 #[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 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 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 #[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 let first = db.add_label(id, "bug").unwrap();
1226 assert!(first, "First add_label should return true");
1227
1228 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 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 #[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 #[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 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 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 let blocked_issues = db.list_blocked_issues().unwrap();
1385 assert_eq!(blocked_issues.len(), 1);
1386
1387 db.close_issue(blocker).unwrap();
1389
1390 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 #[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 #[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 let total = db.get_total_time(id).unwrap();
1473 assert_eq!(total, 0);
1474 }
1475
1476 #[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 #[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 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 #[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 #[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 #[test]
1683 fn test_sql_injection_in_title() {
1684 let (db, _dir) = setup_test_db();
1685
1686 let malicious = "'; DROP TABLE issues; --";
1688 let id = db.create_issue(malicious, None, "medium").unwrap();
1689
1690 let issue = db.get_issue(id).unwrap().unwrap();
1692 assert_eq!(issue.title, malicious);
1693
1694 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 let malicious = "%'; DROP TABLE issues; --";
1734 let results = db.search_issues(malicious).unwrap();
1735
1736 assert!(results.is_empty());
1738
1739 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 #[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 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 assert!(db.get_issue(child_id).unwrap().is_none());
1843 }
1844
1845 #[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 #[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 std::fs::write(&db_path, b"").unwrap();
1886
1887 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 std::fs::write(&db_path, b"not a sqlite database at all!").unwrap();
1909
1910 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 {
1922 let db = Database::open(&db_path).unwrap();
1923 db.create_issue("Test", None, "medium").unwrap();
1924 }
1925
1926 let content = std::fs::read(&db_path).unwrap();
1928 std::fs::write(&db_path, &content[..content.len() / 2]).unwrap();
1929
1930 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 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 #[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 std::fs::write(&db_path, b"").unwrap();
1967
1968 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 let result = Database::open(&db_path);
1975 assert!(result.is_err());
1976 }
1977 }
1978}
1979
1980#[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 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 fn safe_string() -> impl Strategy<Value = String> {
2006 "[a-zA-Z0-9 _\\-\\.!?]{0,1000}".prop_map(|s| s)
2008 }
2009
2010 proptest! {
2011 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
2087 fn prop_blocking_relationship(a in 1i64..100, b in 1i64..100) {
2088 if a == b {
2089 return Ok(()); }
2091 let (db, _dir) = setup_test_db();
2092
2093 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 #[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 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 #[test]
2121 fn prop_no_circular_deps(chain_len in 2usize..6) {
2122 let (db, _dir) = setup_test_db();
2123
2124 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 for i in 0..chain_len - 1 {
2133 db.add_dependency(ids[i], ids[i + 1]).unwrap();
2134 }
2135
2136 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 #[test]
2143 fn prop_cascade_deletes_children(child_count in 1usize..5) {
2144 let (db, _dir) = setup_test_db();
2145
2146 let parent_id = db.create_issue("Parent", None, "medium").unwrap();
2148
2149 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 let issues_before = db.list_issues(None, None, None).unwrap();
2158 prop_assert_eq!(issues_before.len(), child_count + 1);
2159
2160 db.delete_issue(parent_id).unwrap();
2162
2163 let issues_after = db.list_issues(None, None, None).unwrap();
2165 prop_assert_eq!(issues_after.len(), 0);
2166
2167 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 #[test]
2176 fn prop_ready_list_correctness(issue_count in 2usize..8) {
2177 let (db, _dir) = setup_test_db();
2178
2179 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 for i in 0..issue_count - 1 {
2188 let _ = db.add_dependency(ids[i], ids[i + 1]);
2189 }
2190
2191 let ready = db.list_ready_issues().unwrap();
2193
2194 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 #[test]
2211 fn prop_session_issue_delete_cascade(title in safe_string()) {
2212 let (db, _dir) = setup_test_db();
2213
2214 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 let session = db.get_current_session().unwrap().unwrap();
2221 prop_assert_eq!(session.active_issue_id, Some(issue_id));
2222
2223 db.delete_issue(issue_id).unwrap();
2225
2226 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 #[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 let special_title = format!("{}%test_marker{}", prefix, suffix);
2242 db.create_issue(&special_title, None, "medium").unwrap();
2243
2244 db.create_issue("other content here", None, "medium").unwrap();
2246
2247 let results = db.search_issues("%test_").unwrap();
2249
2250 prop_assert!(results.iter().all(|i| i.title.contains("%test_")));
2252 }
2253 }
2254}