Skip to main content

rectilinear_core/db/
mod.rs

1pub mod schema;
2#[cfg(test)]
3mod test_helpers;
4
5use anyhow::{Context, Result};
6use rusqlite::Connection;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::sync::{Arc, Mutex};
10
11pub struct BlockerRow {
12    pub issue_id: String,
13    pub identifier: String,
14    pub title: String,
15    pub state_name: String,
16    pub state_type: String,
17}
18
19#[derive(Clone)]
20pub struct Database {
21    conn: Arc<Mutex<Connection>>,
22}
23
24impl Database {
25    pub fn open(path: &Path) -> Result<Self> {
26        let conn = Connection::open(path)
27            .with_context(|| format!("Failed to open database at {}", path.display()))?;
28
29        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
30
31        let db = Self {
32            conn: Arc::new(Mutex::new(conn)),
33        };
34        db.migrate()?;
35        Ok(db)
36    }
37
38    fn migrate(&self) -> Result<()> {
39        let conn = self.conn.lock().unwrap();
40        schema::run_migrations(&conn)?;
41        Ok(())
42    }
43
44    pub fn with_conn<F, T>(&self, f: F) -> Result<T>
45    where
46        F: FnOnce(&Connection) -> Result<T>,
47    {
48        let conn = self.conn.lock().unwrap();
49        f(&conn)
50    }
51
52    // --- Workspace CRUD ---
53
54    pub fn upsert_workspace(
55        &self,
56        id: &str,
57        linear_org_id: Option<&str>,
58        display_name: Option<&str>,
59    ) -> Result<()> {
60        self.with_conn(|conn| {
61            conn.execute(
62                "INSERT INTO workspaces (id, linear_org_id, display_name)
63                 VALUES (?1, ?2, ?3)
64                 ON CONFLICT(id) DO UPDATE SET
65                   linear_org_id=excluded.linear_org_id,
66                   display_name=excluded.display_name",
67                rusqlite::params![id, linear_org_id, display_name],
68            )?;
69            Ok(())
70        })
71    }
72
73    pub fn get_workspace(&self, id: &str) -> Result<Option<WorkspaceRow>> {
74        self.with_conn(|conn| {
75            let mut stmt = conn.prepare(
76                "SELECT id, linear_org_id, display_name, created_at FROM workspaces WHERE id = ?1",
77            )?;
78            let mut rows = stmt.query(rusqlite::params![id])?;
79            if let Some(row) = rows.next()? {
80                Ok(Some(WorkspaceRow {
81                    id: row.get(0)?,
82                    linear_org_id: row.get(1)?,
83                    display_name: row.get(2)?,
84                    created_at: row.get(3)?,
85                }))
86            } else {
87                Ok(None)
88            }
89        })
90    }
91
92    pub fn list_workspaces(&self) -> Result<Vec<WorkspaceRow>> {
93        self.with_conn(|conn| {
94            let mut stmt = conn.prepare(
95                "SELECT id, linear_org_id, display_name, created_at FROM workspaces ORDER BY id",
96            )?;
97            let rows = stmt.query_map([], |row| {
98                Ok(WorkspaceRow {
99                    id: row.get(0)?,
100                    linear_org_id: row.get(1)?,
101                    display_name: row.get(2)?,
102                    created_at: row.get(3)?,
103                })
104            })?;
105            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
106        })
107    }
108
109    /// Delete a workspace and all its associated data (issues, chunks, comments, sync state).
110    pub fn delete_workspace(&self, id: &str) -> Result<usize> {
111        self.with_conn(|conn| {
112            // Chunks and issue_relations cascade from issues via ON DELETE CASCADE
113            let issue_count: usize = conn.query_row(
114                "SELECT COUNT(*) FROM issues WHERE workspace_id = ?1",
115                rusqlite::params![id],
116                |row| row.get(0),
117            )?;
118            conn.execute(
119                "DELETE FROM issues WHERE workspace_id = ?1",
120                rusqlite::params![id],
121            )?;
122            conn.execute(
123                "DELETE FROM comments WHERE workspace_id = ?1",
124                rusqlite::params![id],
125            )?;
126            conn.execute(
127                "DELETE FROM sync_state WHERE workspace_id = ?1",
128                rusqlite::params![id],
129            )?;
130            conn.execute(
131                "DELETE FROM labels WHERE workspace_id = ?1",
132                rusqlite::params![id],
133            )?;
134            conn.execute(
135                "DELETE FROM workspaces WHERE id = ?1",
136                rusqlite::params![id],
137            )?;
138            Ok(issue_count)
139        })
140    }
141
142    // --- Label CRUD ---
143
144    pub fn upsert_label(&self, label: &Label) -> Result<()> {
145        self.with_conn(|conn| {
146            conn.execute(
147                "INSERT INTO labels (id, workspace_id, name, color, parent_id)
148                 VALUES (?1, ?2, ?3, ?4, ?5)
149                 ON CONFLICT(id) DO UPDATE SET
150                   workspace_id=excluded.workspace_id,
151                   name=excluded.name,
152                   color=excluded.color,
153                   parent_id=excluded.parent_id",
154                rusqlite::params![label.id, label.workspace_id, label.name, label.color, label.parent_id],
155            )?;
156            Ok(())
157        })
158    }
159
160    pub fn list_labels(&self, workspace_id: &str) -> Result<Vec<Label>> {
161        self.with_conn(|conn| {
162            let mut stmt = conn.prepare(
163                "SELECT id, workspace_id, name, color, parent_id
164                 FROM labels WHERE workspace_id = ?1
165                 ORDER BY name COLLATE NOCASE ASC",
166            )?;
167            let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
168                Ok(Label {
169                    id: row.get(0)?,
170                    workspace_id: row.get(1)?,
171                    name: row.get(2)?,
172                    color: row.get(3)?,
173                    parent_id: row.get(4)?,
174                })
175            })?;
176            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
177        })
178    }
179
180    /// Delete labels in `workspace_id` whose id is NOT in `keep_ids`.
181    /// Returns the number of rows deleted. Cascades to `issue_labels`.
182    pub fn delete_labels_for_workspace_not_in(
183        &self,
184        workspace_id: &str,
185        keep_ids: &[String],
186    ) -> Result<usize> {
187        self.with_conn(|conn| {
188            if keep_ids.is_empty() {
189                let n = conn.execute(
190                    "DELETE FROM labels WHERE workspace_id = ?1",
191                    rusqlite::params![workspace_id],
192                )?;
193                return Ok(n);
194            }
195            let placeholders = (0..keep_ids.len())
196                .map(|i| format!("?{}", i + 2))
197                .collect::<Vec<_>>()
198                .join(", ");
199            let sql = format!(
200                "DELETE FROM labels WHERE workspace_id = ?1 AND id NOT IN ({placeholders})"
201            );
202            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
203                vec![Box::new(workspace_id.to_string())];
204            for id in keep_ids {
205                params.push(Box::new(id.clone()));
206            }
207            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
208                params.iter().map(|p| p.as_ref()).collect();
209            let n = conn.execute(&sql, param_refs.as_slice())?;
210            Ok(n)
211        })
212    }
213
214    /// Resolve label names to ids using the local catalog (case-insensitive).
215    /// Returns (resolved_ids, unknown_names). Order of resolved_ids is not guaranteed.
216    pub fn resolve_label_ids_local(
217        &self,
218        workspace_id: &str,
219        names: &[String],
220    ) -> Result<(Vec<String>, Vec<String>)> {
221        if names.is_empty() {
222            return Ok((Vec::new(), Vec::new()));
223        }
224        self.with_conn(|conn| {
225            let mut resolved = Vec::new();
226            let mut unknown = Vec::new();
227            let mut stmt = conn.prepare(
228                "SELECT id FROM labels WHERE workspace_id = ?1 AND name = ?2 COLLATE NOCASE",
229            )?;
230            for name in names {
231                let mut rows = stmt.query(rusqlite::params![workspace_id, name])?;
232                if let Some(row) = rows.next()? {
233                    resolved.push(row.get::<_, String>(0)?);
234                } else {
235                    unknown.push(name.clone());
236                }
237            }
238            Ok((resolved, unknown))
239        })
240    }
241
242    // --- Issue-Label Join CRUD ---
243
244    /// Replace the label set for an issue. Atomic via transaction.
245    /// Skips any label_ids not present in the `labels` table (logged at warn level via eprintln).
246    pub fn replace_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()> {
247        self.with_conn(|conn| {
248            let tx = conn.unchecked_transaction()?;
249            tx.execute(
250                "DELETE FROM issue_labels WHERE issue_id = ?1",
251                rusqlite::params![issue_id],
252            )?;
253            for lid in label_ids {
254                let exists: i64 = tx.query_row(
255                    "SELECT COUNT(*) FROM labels WHERE id = ?1",
256                    rusqlite::params![lid],
257                    |r| r.get(0),
258                )?;
259                if exists == 0 {
260                    eprintln!(
261                        "warning: skipping unknown label id '{}' for issue '{}'",
262                        lid, issue_id
263                    );
264                    continue;
265                }
266                tx.execute(
267                    "INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)",
268                    rusqlite::params![issue_id, lid],
269                )?;
270            }
271            tx.commit()?;
272            Ok(())
273        })
274    }
275
276    pub fn get_issue_label_ids(&self, issue_id: &str) -> Result<Vec<String>> {
277        self.with_conn(|conn| {
278            let mut stmt = conn.prepare(
279                "SELECT label_id FROM issue_labels WHERE issue_id = ?1 ORDER BY label_id",
280            )?;
281            let rows = stmt.query_map(rusqlite::params![issue_id], |row| row.get::<_, String>(0))?;
282            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
283        })
284    }
285
286    // --- Issue CRUD ---
287
288    pub fn upsert_issue(&self, issue: &Issue) -> Result<()> {
289        self.with_conn(|conn| {
290            conn.execute(
291                "INSERT INTO issues (id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url, branch_name, workspace_id)
292                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'), ?15, ?16, ?17)
293                 ON CONFLICT(id) DO UPDATE SET
294                   identifier=excluded.identifier, team_key=excluded.team_key, title=excluded.title,
295                   description=excluded.description, state_name=excluded.state_name, state_type=excluded.state_type,
296                   priority=excluded.priority, assignee_name=excluded.assignee_name, project_name=excluded.project_name,
297                   labels_json=excluded.labels_json, updated_at=excluded.updated_at,
298                   content_hash=excluded.content_hash, url=excluded.url, branch_name=excluded.branch_name,
299                   workspace_id=excluded.workspace_id, synced_at=datetime('now')",
300                rusqlite::params![
301                    issue.id, issue.identifier, issue.team_key, issue.title, issue.description,
302                    issue.state_name, issue.state_type, issue.priority, issue.assignee_name,
303                    issue.project_name, issue.labels_json, issue.created_at, issue.updated_at,
304                    issue.content_hash, issue.url, issue.branch_name, issue.workspace_id,
305                ],
306            )?;
307            Ok(())
308        })
309    }
310
311    pub fn get_issue(&self, id_or_identifier: &str) -> Result<Option<Issue>> {
312        self.with_conn(|conn| {
313            let mut stmt = conn.prepare(
314                "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url, branch_name, workspace_id
315                 FROM issues WHERE id = ?1 OR identifier = ?1"
316            )?;
317            let mut rows = stmt.query(rusqlite::params![id_or_identifier])?;
318            if let Some(row) = rows.next()? {
319                Ok(Some(Issue::from_row(row)?))
320            } else {
321                Ok(None)
322            }
323        })
324    }
325
326    /// Build a SQL fragment "<table_alias>.id IN (SELECT issue_id FROM issue_labels ...)"
327    /// for AND-matching all of `label_ids`. Returns the fragment + bound params.
328    /// Caller is responsible for prepending " AND " before splicing in.
329    /// `param_offset` is the next free `?N` index (1-based).
330    /// `table_alias` is the alias used by the outer query (e.g. "issues" or "i").
331    fn label_filter_fragment(
332        label_ids: &[String],
333        param_offset: usize,
334        table_alias: &str,
335    ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
336        let n = label_ids.len();
337        let placeholders = (0..n)
338            .map(|i| format!("?{}", param_offset + i))
339            .collect::<Vec<_>>()
340            .join(", ");
341        let sql = format!(
342            "{table_alias}.id IN (\
343                SELECT issue_id FROM issue_labels \
344                WHERE label_id IN ({placeholders}) \
345                GROUP BY issue_id \
346                HAVING COUNT(DISTINCT label_id) = {n}\
347             )"
348        );
349        let params: Vec<Box<dyn rusqlite::types::ToSql>> =
350            label_ids.iter().map(|s| Box::new(s.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
351        (sql, params)
352    }
353
354    pub fn get_unprioritized_issues(
355        &self,
356        team_key: Option<&str>,
357        include_completed: bool,
358        workspace_id: &str,
359    ) -> Result<Vec<Issue>> {
360        self.get_unprioritized_issues_filtered(team_key, include_completed, workspace_id, None)
361    }
362
363    pub fn get_unprioritized_issues_filtered(
364        &self,
365        team_key: Option<&str>,
366        include_completed: bool,
367        workspace_id: &str,
368        label_ids: Option<&[String]>,
369    ) -> Result<Vec<Issue>> {
370        self.with_conn(|conn| {
371            let state_filter = if include_completed {
372                ""
373            } else {
374                " AND state_type NOT IN ('completed', 'canceled')"
375            };
376
377            // Required base params come first; label-filter params (if any) are appended.
378            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
379            let base_where: String = if let Some(team) = team_key {
380                params.push(Box::new(team.to_string()));
381                params.push(Box::new(workspace_id.to_string()));
382                "team_key = ?1 AND workspace_id = ?2".to_string()
383            } else {
384                params.push(Box::new(workspace_id.to_string()));
385                "workspace_id = ?1".to_string()
386            };
387
388            let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
389                let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "issues");
390                params.append(&mut lp);
391                format!(" AND {frag}")
392            } else {
393                String::new()
394            };
395
396            let sql = format!(
397                "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url, branch_name, workspace_id
398                 FROM issues WHERE priority = 0{state_filter} AND {base_where}{label_clause}
399                 ORDER BY created_at DESC"
400            );
401
402            let mut stmt = conn.prepare(&sql)?;
403            let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
404            let rows = stmt.query_map(param_refs.as_slice(), |row| Ok(Issue::from_row(row).unwrap()))?;
405            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
406        })
407    }
408
409    pub fn get_issues_by_state_types(
410        &self,
411        team_key: &str,
412        state_types: &[String],
413        workspace_id: &str,
414    ) -> Result<Vec<Issue>> {
415        self.with_conn(|conn| {
416            let placeholders: String = state_types
417                .iter()
418                .enumerate()
419                .map(|(i, _)| format!("?{}", i + 3))
420                .collect::<Vec<_>>()
421                .join(", ");
422            let sql = format!(
423                "SELECT id, identifier, team_key, title, description, state_name, state_type, \
424                 priority, assignee_name, project_name, labels_json, created_at, updated_at, \
425                 content_hash, synced_at, url, branch_name, workspace_id \
426                 FROM issues WHERE team_key = ?1 AND workspace_id = ?2 AND state_type IN ({placeholders}) \
427                 ORDER BY priority ASC, created_at DESC"
428            );
429            let mut stmt = conn.prepare(&sql)?;
430            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
431                vec![Box::new(team_key.to_string()), Box::new(workspace_id.to_string())];
432            for st in state_types {
433                params.push(Box::new(st.clone()));
434            }
435            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
436                params.iter().map(|p| p.as_ref()).collect();
437            let rows = stmt.query_map(param_refs.as_slice(), |row| {
438                Ok(Issue::from_row(row).unwrap())
439            })?;
440            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
441        })
442    }
443
444    /// For a set of issue IDs, return all `blocked_by` relations with resolved state info.
445    /// Returns (issue_id, blocker_identifier, blocker_title, blocker_state_name, blocker_state_type).
446    pub fn get_blockers_for_issues(&self, issue_ids: &[String]) -> Result<Vec<BlockerRow>> {
447        if issue_ids.is_empty() {
448            return Ok(vec![]);
449        }
450        self.with_conn(|conn| {
451            let placeholders: String = issue_ids
452                .iter()
453                .enumerate()
454                .map(|(i, _)| format!("?{}", i + 1))
455                .collect::<Vec<_>>()
456                .join(", ");
457
458            // Forward: issue has a "blocked_by" relation
459            let sql_fwd = format!(
460                "SELECT r.issue_id, COALESCE(i.identifier, r.related_issue_identifier),
461                        COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.state_type, '')
462                 FROM issue_relations r
463                 LEFT JOIN issues i ON r.related_issue_id = i.id
464                 WHERE r.issue_id IN ({placeholders}) AND r.relation_type = 'blocked_by'"
465            );
466
467            // Inverse: another issue has a "blocks" relation pointing at this issue
468            let sql_inv = format!(
469                "SELECT r.related_issue_id, i2.identifier,
470                        COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.state_type, '')
471                 FROM issue_relations r
472                 JOIN issues i ON r.related_issue_id = i.id
473                 JOIN issues i2 ON r.issue_id = i2.id
474                 WHERE r.related_issue_id IN ({placeholders}) AND r.relation_type = 'blocks'"
475            );
476
477            let mut results = Vec::new();
478            let params: Vec<Box<dyn rusqlite::types::ToSql>> =
479                issue_ids.iter().map(|id| Box::new(id.clone()) as _).collect();
480            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
481                params.iter().map(|p| p.as_ref()).collect();
482
483            for sql in [&sql_fwd, &sql_inv] {
484                let mut stmt = conn.prepare(sql)?;
485                let rows = stmt.query_map(param_refs.as_slice(), |row| {
486                    Ok(BlockerRow {
487                        issue_id: row.get(0)?,
488                        identifier: row.get(1)?,
489                        title: row.get(2)?,
490                        state_name: row.get(3)?,
491                        state_type: row.get(4)?,
492                    })
493                })?;
494                for row in rows {
495                    results.push(row?);
496                }
497            }
498            Ok(results)
499        })
500    }
501
502    pub fn count_issues(&self, team_key: Option<&str>, workspace_id: &str) -> Result<usize> {
503        self.with_conn(|conn| {
504            let count: usize = if let Some(team) = team_key {
505                conn.query_row(
506                    "SELECT COUNT(*) FROM issues WHERE team_key = ?1 AND workspace_id = ?2",
507                    rusqlite::params![team, workspace_id],
508                    |row| row.get(0),
509                )?
510            } else {
511                conn.query_row(
512                    "SELECT COUNT(*) FROM issues WHERE workspace_id = ?1",
513                    rusqlite::params![workspace_id],
514                    |row| row.get(0),
515                )?
516            };
517            Ok(count)
518        })
519    }
520
521    /// Count issues with each optional field populated. Returns (total, with_description, with_priority, with_labels, with_project).
522    pub fn get_field_completeness(
523        &self,
524        team_key: Option<&str>,
525        workspace_id: &str,
526    ) -> Result<(usize, usize, usize, usize, usize)> {
527        self.with_conn(|conn| {
528            let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
529                if let Some(team) = team_key {
530                    (
531                        "SELECT COUNT(*),
532                                SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
533                                SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
534                                SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
535                                SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
536                         FROM issues WHERE team_key = ?1 AND workspace_id = ?2"
537                            .to_string(),
538                        vec![Box::new(team.to_string()) as Box<dyn rusqlite::types::ToSql>, Box::new(workspace_id.to_string())],
539                    )
540                } else {
541                    (
542                        "SELECT COUNT(*),
543                                SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
544                                SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
545                                SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
546                                SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
547                         FROM issues WHERE workspace_id = ?1"
548                            .to_string(),
549                        vec![Box::new(workspace_id.to_string()) as Box<dyn rusqlite::types::ToSql>],
550                    )
551                };
552            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
553                params.iter().map(|p| p.as_ref()).collect();
554            let row = conn.query_row(&sql, param_refs.as_slice(), |row| {
555                Ok((
556                    row.get::<_, usize>(0)?,
557                    row.get::<_, Option<usize>>(1)?.unwrap_or(0),
558                    row.get::<_, Option<usize>>(2)?.unwrap_or(0),
559                    row.get::<_, Option<usize>>(3)?.unwrap_or(0),
560                    row.get::<_, Option<usize>>(4)?.unwrap_or(0),
561                ))
562            })?;
563            Ok(row)
564        })
565    }
566
567    /// List all issues with summary info (no description text). Supports pagination,
568    /// optional team filter, and optional text filter on identifier/title.
569    #[allow(unused_assignments)]
570    pub fn list_all_issues(
571        &self,
572        team_key: Option<&str>,
573        filter: Option<&str>,
574        limit: usize,
575        offset: usize,
576        workspace_id: &str,
577    ) -> Result<Vec<IssueSummary>> {
578        self.with_conn(|conn| {
579            let mut conditions = Vec::new();
580            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
581            let mut param_idx = 1;
582
583            // Always filter by workspace
584            conditions.push(format!("i.workspace_id = ?{param_idx}"));
585            params.push(Box::new(workspace_id.to_string()));
586            param_idx += 1;
587
588            if let Some(team) = team_key {
589                conditions.push(format!("i.team_key = ?{param_idx}"));
590                params.push(Box::new(team.to_string()));
591                param_idx += 1;
592            }
593
594            if let Some(text) = filter {
595                let like = format!("%{text}%");
596                conditions.push(format!(
597                    "(i.identifier LIKE ?{} OR i.title LIKE ?{})",
598                    param_idx,
599                    param_idx + 1
600                ));
601                params.push(Box::new(like.clone()));
602                params.push(Box::new(like));
603                param_idx += 2;
604            }
605
606            let _ = param_idx;
607
608            let where_clause = if conditions.is_empty() {
609                String::new()
610            } else {
611                format!("WHERE {}", conditions.join(" AND "))
612            };
613
614            let limit_idx = params.len() + 1;
615            let offset_idx = params.len() + 2;
616
617            let sql = format!(
618                "SELECT i.id, i.identifier, i.team_key, i.title, i.state_name, i.state_type,
619                        i.priority, i.project_name, i.labels_json, i.updated_at, i.url,
620                        i.description IS NOT NULL AND i.description != '' AS has_desc,
621                        EXISTS(SELECT 1 FROM chunks c WHERE c.issue_id = i.id) AS has_emb
622                 FROM issues i
623                 {where_clause}
624                 ORDER BY i.updated_at DESC
625                 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
626            );
627            params.push(Box::new(limit as i64));
628            params.push(Box::new(offset as i64));
629
630            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
631                params.iter().map(|p| p.as_ref()).collect();
632            let mut stmt = conn.prepare(&sql)?;
633            let rows = stmt.query_map(param_refs.as_slice(), |row| {
634                let labels_json: String = row.get(8)?;
635                let labels: Vec<String> = serde_json::from_str(&labels_json).unwrap_or_default();
636                Ok(IssueSummary {
637                    id: row.get(0)?,
638                    identifier: row.get(1)?,
639                    team_key: row.get(2)?,
640                    title: row.get(3)?,
641                    state_name: row.get(4)?,
642                    state_type: row.get(5)?,
643                    priority: row.get(6)?,
644                    project_name: row.get(7)?,
645                    labels,
646                    updated_at: row.get(9)?,
647                    url: row.get(10)?,
648                    has_description: row.get(11)?,
649                    has_embedding: row.get(12)?,
650                })
651            })?;
652            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
653        })
654    }
655
656    // --- Relations ---
657
658    pub fn upsert_relations(&self, issue_id: &str, relations: &[Relation]) -> Result<()> {
659        self.with_conn(|conn| {
660            conn.execute(
661                "DELETE FROM issue_relations WHERE issue_id = ?1",
662                rusqlite::params![issue_id],
663            )?;
664            let mut stmt = conn.prepare(
665                "INSERT OR IGNORE INTO issue_relations (id, issue_id, related_issue_id, related_issue_identifier, relation_type)
666                 VALUES (?1, ?2, ?3, ?4, ?5)"
667            )?;
668            for rel in relations {
669                stmt.execute(rusqlite::params![
670                    rel.id, rel.issue_id, rel.related_issue_id,
671                    rel.related_issue_identifier, rel.relation_type,
672                ])?;
673            }
674            Ok(())
675        })
676    }
677
678    pub fn get_relations_enriched(&self, issue_id: &str) -> Result<Vec<EnrichedRelation>> {
679        self.with_conn(|conn| {
680            // Relations where this issue is the source
681            let mut stmt = conn.prepare(
682                "SELECT r.id, r.relation_type, r.related_issue_identifier,
683                        COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.url, '')
684                 FROM issue_relations r
685                 LEFT JOIN issues i ON r.related_issue_id = i.id
686                 WHERE r.issue_id = ?1",
687            )?;
688            let forward = stmt
689                .query_map(rusqlite::params![issue_id], |row| {
690                    Ok(EnrichedRelation {
691                        relation_id: row.get(0)?,
692                        relation_type: row.get(1)?,
693                        issue_identifier: row.get(2)?,
694                        issue_title: row.get(3)?,
695                        issue_state: row.get(4)?,
696                        issue_url: row.get(5)?,
697                    })
698                })?
699                .collect::<std::result::Result<Vec<_>, _>>()?;
700
701            // Relations where this issue is the target — flip direction
702            let mut stmt2 = conn.prepare(
703                "SELECT r.id, r.relation_type, i2.identifier,
704                        COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.url, '')
705                 FROM issue_relations r
706                 JOIN issues i ON r.related_issue_id = i.id
707                 JOIN issues i2 ON r.issue_id = i2.id
708                 WHERE r.related_issue_id = i.id AND i.id = ?1",
709            )?;
710            let inverse = stmt2
711                .query_map(rusqlite::params![issue_id], |row| {
712                    let raw_type: String = row.get(1)?;
713                    let flipped = match raw_type.as_str() {
714                        "blocks" => "blocked_by".to_string(),
715                        "blocked_by" => "blocks".to_string(),
716                        other => other.to_string(), // related, duplicate are symmetric
717                    };
718                    Ok(EnrichedRelation {
719                        relation_id: row.get(0)?,
720                        relation_type: flipped,
721                        issue_identifier: row.get(2)?,
722                        issue_title: row.get(3)?,
723                        issue_state: row.get(4)?,
724                        issue_url: row.get(5)?,
725                    })
726                })?
727                .collect::<std::result::Result<Vec<_>, _>>()?;
728
729            let mut all = forward;
730            all.extend(inverse);
731            Ok(all)
732        })
733    }
734
735    /// Look up a relation ID between two issues (by identifier) for deletion
736    pub fn find_relation_id(
737        &self,
738        issue_id: &str,
739        related_issue_id: &str,
740        relation_type: &str,
741    ) -> Result<Option<String>> {
742        self.with_conn(|conn| {
743            let mut stmt = conn.prepare(
744                "SELECT id FROM issue_relations WHERE issue_id = ?1 AND related_issue_id = ?2 AND relation_type = ?3"
745            )?;
746            let mut rows = stmt.query(rusqlite::params![issue_id, related_issue_id, relation_type])?;
747            if let Some(row) = rows.next()? {
748                Ok(Some(row.get(0)?))
749            } else {
750                Ok(None)
751            }
752        })
753    }
754
755    // --- Chunks (embeddings) ---
756
757    pub fn upsert_chunks(&self, issue_id: &str, chunks: &[(usize, String, Vec<u8>)]) -> Result<()> {
758        self.upsert_chunks_with_model(issue_id, chunks, "")
759    }
760
761    pub fn upsert_chunks_with_model(
762        &self,
763        issue_id: &str,
764        chunks: &[(usize, String, Vec<u8>)],
765        model_name: &str,
766    ) -> Result<()> {
767        self.with_conn(|conn| {
768            conn.execute(
769                "DELETE FROM chunks WHERE issue_id = ?1",
770                rusqlite::params![issue_id],
771            )?;
772            let mut stmt = conn.prepare(
773                "INSERT INTO chunks (issue_id, chunk_index, chunk_text, embedding, model_name) VALUES (?1, ?2, ?3, ?4, ?5)"
774            )?;
775            for (idx, text, embedding) in chunks {
776                stmt.execute(rusqlite::params![issue_id, idx, text, embedding, model_name])?;
777            }
778            Ok(())
779        })
780    }
781
782    /// Get the embedding model name for an issue's chunks, if any exist.
783    pub fn get_embedding_model(&self, issue_id: &str) -> Result<Option<String>> {
784        self.with_conn(|conn| {
785            let mut stmt =
786                conn.prepare("SELECT model_name FROM chunks WHERE issue_id = ?1 LIMIT 1")?;
787            let mut rows = stmt.query(rusqlite::params![issue_id])?;
788            if let Some(row) = rows.next()? {
789                let name: String = row.get(0)?;
790                Ok(if name.is_empty() { None } else { Some(name) })
791            } else {
792                Ok(None)
793            }
794        })
795    }
796
797    pub fn get_all_chunks(&self, workspace_id: &str) -> Result<Vec<Chunk>> {
798        self.with_conn(|conn| {
799            let mut stmt = conn.prepare(
800                "SELECT c.issue_id, c.embedding, i.identifier
801                 FROM chunks c JOIN issues i ON c.issue_id = i.id
802                 WHERE i.workspace_id = ?1",
803            )?;
804            let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
805                Ok(Chunk {
806                    issue_id: row.get(0)?,
807                    embedding: row.get(1)?,
808                    identifier: row.get(2)?,
809                })
810            })?;
811            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
812        })
813    }
814
815    pub fn get_chunks_for_team(&self, team_key: &str, workspace_id: &str) -> Result<Vec<Chunk>> {
816        self.with_conn(|conn| {
817            let mut stmt = conn.prepare(
818                "SELECT c.issue_id, c.embedding, i.identifier
819                 FROM chunks c JOIN issues i ON c.issue_id = i.id
820                 WHERE i.team_key = ?1 AND i.workspace_id = ?2",
821            )?;
822            let rows = stmt.query_map(rusqlite::params![team_key, workspace_id], |row| {
823                Ok(Chunk {
824                    issue_id: row.get(0)?,
825                    embedding: row.get(1)?,
826                    identifier: row.get(2)?,
827                })
828            })?;
829            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
830        })
831    }
832
833    pub fn count_embedded_issues(
834        &self,
835        team_key: Option<&str>,
836        workspace_id: &str,
837    ) -> Result<usize> {
838        self.with_conn(|conn| {
839            let count: usize = if let Some(team) = team_key {
840                conn.query_row(
841                    "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.team_key = ?1 AND i.workspace_id = ?2",
842                    rusqlite::params![team, workspace_id],
843                    |row| row.get(0),
844                )?
845            } else {
846                conn.query_row(
847                    "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.workspace_id = ?1",
848                    rusqlite::params![workspace_id],
849                    |row| row.get(0),
850                )?
851            };
852            Ok(count)
853        })
854    }
855
856    pub fn get_issues_needing_embedding(
857        &self,
858        team_key: Option<&str>,
859        force: bool,
860        workspace_id: &str,
861    ) -> Result<Vec<Issue>> {
862        self.with_conn(|conn| {
863            let sql = if force {
864                if let Some(team) = team_key {
865                    format!(
866                        "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url, branch_name, workspace_id
867                         FROM issues WHERE team_key = '{}' AND workspace_id = '{}'", team, workspace_id
868                    )
869                } else {
870                    format!(
871                        "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url, branch_name, workspace_id
872                         FROM issues WHERE workspace_id = '{}'", workspace_id
873                    )
874                }
875            } else {
876                let team_filter = if let Some(team) = team_key {
877                    format!("AND i.team_key = '{}'", team)
878                } else {
879                    String::new()
880                };
881                format!(
882                    "SELECT i.id, i.identifier, i.team_key, i.title, i.description, i.state_name, i.state_type, i.priority, i.assignee_name, i.project_name, i.labels_json, i.created_at, i.updated_at, i.content_hash, i.synced_at, i.url, i.branch_name, i.workspace_id
883                     FROM issues i
884                     LEFT JOIN (SELECT DISTINCT issue_id FROM chunks) c ON i.id = c.issue_id
885                     WHERE c.issue_id IS NULL AND i.workspace_id = '{}' {}",
886                    workspace_id, team_filter
887                )
888            };
889            let mut stmt = conn.prepare(&sql)?;
890            let rows = stmt.query_map([], |row| {
891                Ok(Issue::from_row(row).unwrap())
892            })?;
893            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
894        })
895    }
896
897    // --- Comments ---
898
899    pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
900        self.with_conn(|conn| {
901            let mut stmt = conn.prepare(
902                "SELECT id, issue_id, body, user_name, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at"
903            )?;
904            let rows = stmt.query_map(rusqlite::params![issue_id], |row| {
905                Ok(Comment {
906                    id: row.get(0)?,
907                    issue_id: row.get(1)?,
908                    body: row.get(2)?,
909                    user_name: row.get(3)?,
910                    created_at: row.get(4)?,
911                })
912            })?;
913            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
914        })
915    }
916
917    // --- Sync state ---
918
919    pub fn get_sync_cursor(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
920        self.with_conn(|conn| {
921            let mut stmt = conn.prepare(
922                "SELECT last_updated_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
923            )?;
924            let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
925            if let Some(row) = rows.next()? {
926                Ok(Some(row.get(0)?))
927            } else {
928                Ok(None)
929            }
930        })
931    }
932
933    pub fn set_sync_cursor(
934        &self,
935        workspace_id: &str,
936        team_key: &str,
937        last_updated_at: &str,
938    ) -> Result<()> {
939        self.with_conn(|conn| {
940            conn.execute(
941                "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
942                 VALUES (?1, ?2, ?3, 1, datetime('now'))
943                 ON CONFLICT(workspace_id, team_key) DO UPDATE SET last_updated_at=excluded.last_updated_at, full_sync_done=1, last_synced_at=datetime('now')",
944                rusqlite::params![workspace_id, team_key, last_updated_at],
945            )?;
946            Ok(())
947        })
948    }
949
950    pub fn is_full_sync_done(&self, workspace_id: &str, team_key: &str) -> Result<bool> {
951        self.with_conn(|conn| {
952            let mut stmt = conn.prepare(
953                "SELECT full_sync_done FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
954            )?;
955            let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
956            if let Some(row) = rows.next()? {
957                let done: bool = row.get(0)?;
958                Ok(done)
959            } else {
960                Ok(false)
961            }
962        })
963    }
964
965    /// Get the wall-clock time of the last sync for a team.
966    pub fn get_last_synced_at(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
967        self.with_conn(|conn| {
968            let mut stmt = conn.prepare(
969                "SELECT last_synced_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
970            )?;
971            let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
972            if let Some(row) = rows.next()? {
973                Ok(row.get(0)?)
974            } else {
975                Ok(None)
976            }
977        })
978    }
979
980    // --- Metadata ---
981
982    pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
983        self.with_conn(|conn| {
984            let mut stmt = conn.prepare("SELECT value FROM metadata WHERE key = ?1")?;
985            let mut rows = stmt.query(rusqlite::params![key])?;
986            if let Some(row) = rows.next()? {
987                Ok(Some(row.get(0)?))
988            } else {
989                Ok(None)
990            }
991        })
992    }
993
994    pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
995        self.with_conn(|conn| {
996            conn.execute(
997                "INSERT INTO metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
998                rusqlite::params![key, value],
999            )?;
1000            Ok(())
1001        })
1002    }
1003
1004    // --- FTS search ---
1005
1006    /// List teams that have synced issues, with issue and embedding counts.
1007    /// Local-only query — no network required.
1008    pub fn list_synced_teams(&self, workspace_id: &str) -> Result<Vec<TeamSummary>> {
1009        self.with_conn(|conn| {
1010            let mut stmt = conn.prepare(
1011                "SELECT i.team_key,
1012                        COUNT(DISTINCT i.id) AS issue_count,
1013                        COUNT(DISTINCT c.issue_id) AS embedded_count,
1014                        s.last_synced_at
1015                 FROM issues i
1016                 LEFT JOIN chunks c ON i.id = c.issue_id
1017                 LEFT JOIN sync_state s ON i.team_key = s.team_key AND s.workspace_id = ?1
1018                 WHERE i.workspace_id = ?1
1019                 GROUP BY i.team_key
1020                 ORDER BY i.team_key",
1021            )?;
1022            let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
1023                Ok(TeamSummary {
1024                    key: row.get(0)?,
1025                    issue_count: row.get(1)?,
1026                    embedded_count: row.get(2)?,
1027                    last_synced_at: row.get(3)?,
1028                })
1029            })?;
1030            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1031        })
1032    }
1033
1034    pub fn fts_search(
1035        &self,
1036        query: &str,
1037        limit: usize,
1038        workspace_id: &str,
1039    ) -> Result<Vec<FtsResult>> {
1040        self.fts_search_filtered(query, limit, workspace_id, None)
1041    }
1042
1043    pub fn fts_search_filtered(
1044        &self,
1045        query: &str,
1046        limit: usize,
1047        workspace_id: &str,
1048        label_ids: Option<&[String]>,
1049    ) -> Result<Vec<FtsResult>> {
1050        self.with_conn(|conn| {
1051            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1052                Box::new(query.to_string()),
1053                Box::new(limit as i64),
1054                Box::new(workspace_id.to_string()),
1055            ];
1056
1057            let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
1058                let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "i");
1059                params.append(&mut lp);
1060                format!(" AND {frag}")
1061            } else {
1062                String::new()
1063            };
1064
1065            let sql = format!(
1066                "SELECT i.id, i.identifier, i.title, i.state_name, i.priority, bm25(issues_fts) as rank
1067                 FROM issues_fts f
1068                 JOIN issues i ON f.rowid = i.rowid
1069                 WHERE issues_fts MATCH ?1 AND i.workspace_id = ?3{label_clause}
1070                 ORDER BY rank
1071                 LIMIT ?2"
1072            );
1073            let mut stmt = conn.prepare(&sql)?;
1074            let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1075            let rows = stmt.query_map(param_refs.as_slice(), |row| {
1076                Ok(FtsResult {
1077                    issue_id: row.get(0)?,
1078                    identifier: row.get(1)?,
1079                    title: row.get(2)?,
1080                    state_name: row.get(3)?,
1081                    priority: row.get(4)?,
1082                    bm25_score: row.get(5)?,
1083                })
1084            })?;
1085            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1086        })
1087    }
1088}
1089
1090// --- Data types ---
1091
1092fn default_workspace_id() -> String {
1093    "default".to_string()
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097pub struct Issue {
1098    pub id: String,
1099    pub identifier: String,
1100    pub team_key: String,
1101    pub title: String,
1102    pub description: Option<String>,
1103    pub state_name: String,
1104    pub state_type: String,
1105    pub priority: i32,
1106    pub assignee_name: Option<String>,
1107    pub project_name: Option<String>,
1108    pub labels_json: String,
1109    pub created_at: String,
1110    pub updated_at: String,
1111    pub content_hash: String,
1112    pub synced_at: Option<String>,
1113    pub url: String,
1114    pub branch_name: Option<String>,
1115    #[serde(default = "default_workspace_id")]
1116    pub workspace_id: String,
1117}
1118
1119impl Issue {
1120    pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
1121        Ok(Self {
1122            id: row.get(0)?,
1123            identifier: row.get(1)?,
1124            team_key: row.get(2)?,
1125            title: row.get(3)?,
1126            description: row.get(4)?,
1127            state_name: row.get(5)?,
1128            state_type: row.get(6)?,
1129            priority: row.get(7)?,
1130            assignee_name: row.get(8)?,
1131            project_name: row.get(9)?,
1132            labels_json: row.get(10)?,
1133            created_at: row.get(11)?,
1134            updated_at: row.get(12)?,
1135            content_hash: row.get(13)?,
1136            synced_at: row.get(14)?,
1137            url: row.get(15)?,
1138            branch_name: row.get(16).unwrap_or(None),
1139            workspace_id: row.get(17).unwrap_or_else(|_| "default".to_string()),
1140        })
1141    }
1142
1143    pub fn labels(&self) -> Vec<String> {
1144        serde_json::from_str(&self.labels_json).unwrap_or_default()
1145    }
1146
1147    pub fn priority_label(&self) -> &str {
1148        match self.priority {
1149            0 => "No priority",
1150            1 => "Urgent",
1151            2 => "High",
1152            3 => "Medium",
1153            4 => "Low",
1154            _ => "Unknown",
1155        }
1156    }
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct Relation {
1161    pub id: String,
1162    pub issue_id: String,
1163    pub related_issue_id: String,
1164    pub related_issue_identifier: String,
1165    pub relation_type: String,
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1169pub struct EnrichedRelation {
1170    pub relation_id: String,
1171    pub relation_type: String,
1172    pub issue_identifier: String,
1173    pub issue_title: String,
1174    pub issue_state: String,
1175    pub issue_url: String,
1176}
1177
1178#[derive(Debug, Clone)]
1179pub struct Chunk {
1180    pub issue_id: String,
1181    pub embedding: Vec<u8>,
1182    pub identifier: String,
1183}
1184
1185#[derive(Debug, Clone, Serialize, Deserialize)]
1186pub struct Comment {
1187    pub id: String,
1188    pub issue_id: String,
1189    pub body: String,
1190    pub user_name: Option<String>,
1191    pub created_at: String,
1192}
1193
1194#[derive(Debug, Clone)]
1195pub struct FtsResult {
1196    pub issue_id: String,
1197    pub identifier: String,
1198    pub title: String,
1199    pub state_name: String,
1200    pub priority: i32,
1201    pub bm25_score: f64,
1202}
1203
1204#[derive(Debug, Clone)]
1205pub struct IssueSummary {
1206    pub id: String,
1207    pub identifier: String,
1208    pub team_key: String,
1209    pub title: String,
1210    pub state_name: String,
1211    pub state_type: String,
1212    pub priority: i32,
1213    pub project_name: Option<String>,
1214    pub labels: Vec<String>,
1215    pub updated_at: String,
1216    pub url: String,
1217    pub has_description: bool,
1218    pub has_embedding: bool,
1219}
1220
1221#[derive(Debug, Clone)]
1222pub struct TeamSummary {
1223    pub key: String,
1224    pub issue_count: usize,
1225    pub embedded_count: usize,
1226    pub last_synced_at: Option<String>,
1227}
1228
1229#[derive(Debug, Clone, Serialize, Deserialize)]
1230pub struct WorkspaceRow {
1231    pub id: String,
1232    pub linear_org_id: Option<String>,
1233    pub display_name: Option<String>,
1234    pub created_at: String,
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize)]
1238pub struct Label {
1239    pub id: String,
1240    pub workspace_id: String,
1241    pub name: String,
1242    pub color: Option<String>,
1243    pub parent_id: Option<String>,
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::test_helpers::*;
1249
1250    #[test]
1251    fn count_embedded_issues_empty_db() {
1252        let (db, _dir) = test_db();
1253        assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 0);
1254    }
1255
1256    #[test]
1257    fn count_embedded_issues_with_data() {
1258        let (db, _dir) = test_db();
1259
1260        let issue1 = make_issue("TST-1", "TST");
1261        let issue2 = make_issue("TST-2", "TST");
1262        let issue3 = make_issue("OTH-1", "OTH");
1263        db.upsert_issue(&issue1).unwrap();
1264        db.upsert_issue(&issue2).unwrap();
1265        db.upsert_issue(&issue3).unwrap();
1266
1267        // Only issue1 and issue3 have embeddings
1268        db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1269            .unwrap();
1270        db.upsert_chunks(&issue3.id, &[(0, "chunk".into(), fake_embedding(768))])
1271            .unwrap();
1272
1273        // Global count
1274        assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 2);
1275        // Team filter
1276        assert_eq!(db.count_embedded_issues(Some("TST"), "default").unwrap(), 1);
1277        assert_eq!(db.count_embedded_issues(Some("OTH"), "default").unwrap(), 1);
1278        assert_eq!(
1279            db.count_embedded_issues(Some("NONE"), "default").unwrap(),
1280            0
1281        );
1282    }
1283
1284    #[test]
1285    fn get_field_completeness_empty_db() {
1286        let (db, _dir) = test_db();
1287        let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1288        assert_eq!(total, 0);
1289        assert_eq!(desc, 0);
1290        assert_eq!(pri, 0);
1291        assert_eq!(labels, 0);
1292        assert_eq!(proj, 0);
1293    }
1294
1295    #[test]
1296    fn get_field_completeness_with_data() {
1297        let (db, _dir) = test_db();
1298
1299        // Issue with all fields
1300        let mut full = make_issue("TST-1", "TST");
1301        full.description = Some("Has desc".into());
1302        full.priority = 2;
1303        full.labels_json = r#"["bug"]"#.into();
1304        full.project_name = Some("Proj".into());
1305        db.upsert_issue(&full).unwrap();
1306
1307        // Issue with no optional fields
1308        let mut sparse = make_issue("TST-2", "TST");
1309        sparse.description = None;
1310        sparse.priority = 0;
1311        sparse.labels_json = "[]".into();
1312        sparse.project_name = None;
1313        db.upsert_issue(&sparse).unwrap();
1314
1315        // Issue on different team
1316        let mut other = make_issue("OTH-1", "OTH");
1317        other.description = Some("Has desc".into());
1318        other.priority = 0;
1319        other.labels_json = "[]".into();
1320        other.project_name = None;
1321        db.upsert_issue(&other).unwrap();
1322
1323        // Global
1324        let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1325        assert_eq!(total, 3);
1326        assert_eq!(desc, 2); // full + other
1327        assert_eq!(pri, 1); // full only
1328        assert_eq!(labels, 1); // full only
1329        assert_eq!(proj, 1); // full only
1330
1331        // Team filter
1332        let (total, desc, pri, labels, proj) =
1333            db.get_field_completeness(Some("TST"), "default").unwrap();
1334        assert_eq!(total, 2);
1335        assert_eq!(desc, 1);
1336        assert_eq!(pri, 1);
1337        assert_eq!(labels, 1);
1338        assert_eq!(proj, 1);
1339    }
1340
1341    #[test]
1342    fn list_all_issues_pagination_and_filter() {
1343        let (db, _dir) = test_db();
1344
1345        for i in 1..=5 {
1346            let mut issue = make_issue(&format!("TST-{i}"), "TST");
1347            issue.updated_at = format!("2026-01-0{i}T00:00:00Z");
1348            db.upsert_issue(&issue).unwrap();
1349        }
1350        let mut other = make_issue("OTH-1", "OTH");
1351        other.updated_at = "2026-01-06T00:00:00Z".to_string();
1352        db.upsert_issue(&other).unwrap();
1353
1354        // All issues, first page
1355        let page1 = db.list_all_issues(None, None, 3, 0, "default").unwrap();
1356        assert_eq!(page1.len(), 3);
1357        // Ordered by updated_at DESC — OTH-1 is newest
1358        assert_eq!(page1[0].identifier, "OTH-1");
1359
1360        // Second page
1361        let page2 = db.list_all_issues(None, None, 3, 3, "default").unwrap();
1362        assert_eq!(page2.len(), 3);
1363
1364        // Third page (empty)
1365        let page3 = db.list_all_issues(None, None, 3, 6, "default").unwrap();
1366        assert_eq!(page3.len(), 0);
1367
1368        // Team filter
1369        let tst = db
1370            .list_all_issues(Some("TST"), None, 10, 0, "default")
1371            .unwrap();
1372        assert_eq!(tst.len(), 5);
1373
1374        // Text filter
1375        let filtered = db
1376            .list_all_issues(None, Some("TST-3"), 10, 0, "default")
1377            .unwrap();
1378        assert_eq!(filtered.len(), 1);
1379        assert_eq!(filtered[0].identifier, "TST-3");
1380
1381        // Title filter
1382        let title_match = db
1383            .list_all_issues(None, Some("Test issue OTH"), 10, 0, "default")
1384            .unwrap();
1385        assert_eq!(title_match.len(), 1);
1386    }
1387
1388    #[test]
1389    fn list_all_issues_has_embedding_flag() {
1390        let (db, _dir) = test_db();
1391
1392        let issue1 = make_issue("TST-1", "TST");
1393        let issue2 = make_issue("TST-2", "TST");
1394        db.upsert_issue(&issue1).unwrap();
1395        db.upsert_issue(&issue2).unwrap();
1396
1397        // Only issue1 gets an embedding
1398        db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1399            .unwrap();
1400
1401        let issues = db.list_all_issues(None, None, 10, 0, "default").unwrap();
1402        let by_id: std::collections::HashMap<_, _> =
1403            issues.iter().map(|i| (i.identifier.as_str(), i)).collect();
1404
1405        assert!(by_id["TST-1"].has_embedding);
1406        assert!(!by_id["TST-2"].has_embedding);
1407    }
1408
1409    #[test]
1410    fn list_synced_teams_empty_db() {
1411        let (db, _dir) = test_db();
1412        let teams = db.list_synced_teams("default").unwrap();
1413        assert!(teams.is_empty());
1414    }
1415
1416    #[test]
1417    fn list_synced_teams_with_data() {
1418        let (db, _dir) = test_db();
1419
1420        // 3 issues on TST, 1 on OTH
1421        for i in 1..=3 {
1422            let issue = make_issue(&format!("TST-{i}"), "TST");
1423            db.upsert_issue(&issue).unwrap();
1424            if i <= 2 {
1425                // Embed first 2
1426                db.upsert_chunks(&issue.id, &[(0, "chunk".into(), fake_embedding(768))])
1427                    .unwrap();
1428            }
1429        }
1430        let other = make_issue("OTH-1", "OTH");
1431        db.upsert_issue(&other).unwrap();
1432
1433        let teams = db.list_synced_teams("default").unwrap();
1434        assert_eq!(teams.len(), 2);
1435
1436        // Sorted by team_key
1437        let by_key: std::collections::HashMap<_, _> =
1438            teams.iter().map(|t| (t.key.as_str(), t)).collect();
1439
1440        assert_eq!(by_key["TST"].issue_count, 3);
1441        assert_eq!(by_key["TST"].embedded_count, 2);
1442        assert_eq!(by_key["OTH"].issue_count, 1);
1443        assert_eq!(by_key["OTH"].embedded_count, 0);
1444    }
1445
1446    #[test]
1447    fn list_synced_teams_includes_last_synced_at() {
1448        let (db, _dir) = test_db();
1449
1450        let issue = make_issue("TST-1", "TST");
1451        db.upsert_issue(&issue).unwrap();
1452
1453        // Before any sync, last_synced_at should be None
1454        let teams = db.list_synced_teams("default").unwrap();
1455        assert_eq!(teams.len(), 1);
1456        assert!(teams[0].last_synced_at.is_none());
1457
1458        // After setting sync cursor, last_synced_at should be set
1459        db.set_sync_cursor("default", "TST", "2026-01-01T00:00:00Z")
1460            .unwrap();
1461        let teams = db.list_synced_teams("default").unwrap();
1462        assert!(teams[0].last_synced_at.is_some());
1463    }
1464
1465    #[test]
1466    fn list_synced_teams_multi_chunk_issue() {
1467        let (db, _dir) = test_db();
1468
1469        let issue = make_issue("TST-1", "TST");
1470        db.upsert_issue(&issue).unwrap();
1471        // Insert multiple chunks for the same issue — count should still be 1
1472        db.upsert_chunks(
1473            &issue.id,
1474            &[
1475                (0, "chunk0".into(), fake_embedding(768)),
1476                (1, "chunk1".into(), fake_embedding(768)),
1477                (2, "chunk2".into(), fake_embedding(768)),
1478            ],
1479        )
1480        .unwrap();
1481
1482        let teams = db.list_synced_teams("default").unwrap();
1483        assert_eq!(teams.len(), 1);
1484        assert_eq!(teams[0].issue_count, 1); // not 3
1485        assert_eq!(teams[0].embedded_count, 1);
1486    }
1487
1488    #[test]
1489    fn workspace_crud() {
1490        let (db, _dir) = test_db();
1491
1492        // Default workspace exists from migration
1493        let ws = db.get_workspace("default").unwrap();
1494        assert!(ws.is_some());
1495
1496        // Upsert a new workspace
1497        db.upsert_workspace("work", None, None).unwrap();
1498        let ws = db.get_workspace("work").unwrap().unwrap();
1499        assert_eq!(ws.id, "work");
1500        assert!(ws.linear_org_id.is_none());
1501
1502        // Update with org info
1503        db.upsert_workspace("work", Some("org-123"), Some("Work Org"))
1504            .unwrap();
1505        let ws = db.get_workspace("work").unwrap().unwrap();
1506        assert_eq!(ws.linear_org_id.as_deref(), Some("org-123"));
1507        assert_eq!(ws.display_name.as_deref(), Some("Work Org"));
1508
1509        // List all
1510        let all = db.list_workspaces().unwrap();
1511        assert_eq!(all.len(), 2);
1512
1513        // Delete
1514        db.delete_workspace("work").unwrap();
1515        let ws = db.get_workspace("work").unwrap();
1516        assert!(ws.is_none());
1517    }
1518
1519    #[test]
1520    fn issues_isolated_by_workspace() {
1521        let (db, _dir) = test_db();
1522
1523        // Create second workspace
1524        db.upsert_workspace("work", None, None).unwrap();
1525
1526        // Insert issue in default workspace
1527        let mut issue1 = make_issue("TST-1", "TST");
1528        issue1.workspace_id = "default".to_string();
1529        issue1.priority = 0;
1530        db.upsert_issue(&issue1).unwrap();
1531
1532        // Insert issue in work workspace
1533        let mut issue2 = make_issue("TST-2", "TST");
1534        issue2.id = "id-2".to_string();
1535        issue2.workspace_id = "work".to_string();
1536        issue2.priority = 0;
1537        db.upsert_issue(&issue2).unwrap();
1538
1539        // Count scoped to each workspace
1540        assert_eq!(db.count_issues(None, "default").unwrap(), 1);
1541        assert_eq!(db.count_issues(None, "work").unwrap(), 1);
1542
1543        // Unprioritized scoped
1544        let default_unpri = db.get_unprioritized_issues(None, false, "default").unwrap();
1545        assert_eq!(default_unpri.len(), 1);
1546        assert_eq!(default_unpri[0].identifier, "TST-1");
1547
1548        let work_unpri = db.get_unprioritized_issues(None, false, "work").unwrap();
1549        assert_eq!(work_unpri.len(), 1);
1550        assert_eq!(work_unpri[0].identifier, "TST-2");
1551    }
1552
1553    #[test]
1554    fn sync_state_isolated_by_workspace() {
1555        let (db, _dir) = test_db();
1556        db.upsert_workspace("work", None, None).unwrap();
1557
1558        // Set cursor for same team in different workspaces
1559        db.set_sync_cursor("default", "TST", "2024-01-01T00:00:00Z")
1560            .unwrap();
1561        db.set_sync_cursor("work", "TST", "2024-06-01T00:00:00Z")
1562            .unwrap();
1563
1564        assert_eq!(
1565            db.get_sync_cursor("default", "TST").unwrap().as_deref(),
1566            Some("2024-01-01T00:00:00Z")
1567        );
1568        assert_eq!(
1569            db.get_sync_cursor("work", "TST").unwrap().as_deref(),
1570            Some("2024-06-01T00:00:00Z")
1571        );
1572
1573        assert!(db.is_full_sync_done("default", "TST").unwrap());
1574        assert!(db.is_full_sync_done("work", "TST").unwrap());
1575        assert!(!db.is_full_sync_done("default", "OTHER").unwrap());
1576    }
1577
1578    #[test]
1579    fn list_synced_teams_workspace_scoped() {
1580        let (db, _dir) = test_db();
1581        db.upsert_workspace("work", None, None).unwrap();
1582
1583        let mut issue1 = make_issue("TST-1", "TST");
1584        issue1.workspace_id = "default".to_string();
1585        db.upsert_issue(&issue1).unwrap();
1586
1587        let mut issue2 = make_issue("WRK-1", "WRK");
1588        issue2.id = "id-wrk".to_string();
1589        issue2.workspace_id = "work".to_string();
1590        db.upsert_issue(&issue2).unwrap();
1591
1592        let default_teams = db.list_synced_teams("default").unwrap();
1593        assert_eq!(default_teams.len(), 1);
1594        assert_eq!(default_teams[0].key, "TST");
1595
1596        let work_teams = db.list_synced_teams("work").unwrap();
1597        assert_eq!(work_teams.len(), 1);
1598        assert_eq!(work_teams[0].key, "WRK");
1599    }
1600
1601    #[test]
1602    fn migration_8_creates_label_tables_and_resets_sync_state() {
1603        let dir = tempfile::tempdir().unwrap();
1604        let path = dir.path().join("test.db");
1605        let conn = rusqlite::Connection::open(&path).unwrap();
1606
1607        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
1608            .unwrap();
1609
1610        // Run migrations 1-7 only
1611        crate::db::schema::run_migrations(&conn).unwrap();
1612
1613        // Delete from schema_version to simulate being at version 7
1614        conn.execute("DELETE FROM schema_version WHERE version = 8", [])
1615            .unwrap();
1616
1617        // Seed sync_state as if a prior sync had completed
1618        conn.execute(
1619            "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
1620             VALUES ('default', 'ENG', '2026-04-01T00:00:00Z', 1, '2026-04-01T00:00:00Z')",
1621            [],
1622        )
1623        .unwrap();
1624
1625        // Verify the sync_state row before migration 8
1626        let full_done_before: i64 = conn
1627            .query_row(
1628                "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1629                [],
1630                |r| r.get(0),
1631            )
1632            .unwrap();
1633        assert_eq!(full_done_before, 1);
1634
1635        // Run migrations again — migration 8 should now run
1636        crate::db::schema::run_migrations(&conn).unwrap();
1637
1638        // Tables exist
1639        let labels_count: i64 = conn
1640            .query_row(
1641                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='labels'",
1642                [],
1643                |r| r.get(0),
1644            )
1645            .unwrap();
1646        assert_eq!(labels_count, 1);
1647        let join_count: i64 = conn
1648            .query_row(
1649                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='issue_labels'",
1650                [],
1651                |r| r.get(0),
1652            )
1653            .unwrap();
1654        assert_eq!(join_count, 1);
1655
1656        // sync_state reset
1657        let full_done: i64 = conn
1658            .query_row(
1659                "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1660                [],
1661                |r| r.get(0),
1662            )
1663            .unwrap();
1664        assert_eq!(full_done, 0);
1665        let last_updated: String = conn
1666            .query_row(
1667                "SELECT last_updated_at FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1668                [],
1669                |r| r.get(0),
1670            )
1671            .unwrap();
1672        assert_eq!(last_updated, "1970-01-01T00:00:00Z");
1673    }
1674
1675    #[test]
1676    fn upsert_label_inserts_and_renames_in_place() {
1677        use super::test_helpers::{test_db, make_label};
1678        let (db, _dir) = test_db();
1679
1680        let mut l = make_label("lbl_1", "Vanta", "default");
1681        db.upsert_label(&l).unwrap();
1682
1683        let listed = db.list_labels("default").unwrap();
1684        assert_eq!(listed.len(), 1);
1685        assert_eq!(listed[0].name, "Vanta");
1686
1687        // Rename — same id, new name
1688        l.name = "Compliance".to_string();
1689        db.upsert_label(&l).unwrap();
1690
1691        let listed = db.list_labels("default").unwrap();
1692        assert_eq!(listed.len(), 1);
1693        assert_eq!(listed[0].name, "Compliance");
1694    }
1695
1696    #[test]
1697    fn list_labels_is_workspace_scoped_and_sorted() {
1698        use super::test_helpers::{test_db, make_label};
1699        let (db, _dir) = test_db();
1700        db.upsert_workspace("work", None, None).unwrap();
1701
1702        db.upsert_label(&make_label("a", "Zebra", "default")).unwrap();
1703        db.upsert_label(&make_label("b", "Apple", "default")).unwrap();
1704        db.upsert_label(&make_label("c", "OnlyInWork", "work")).unwrap();
1705
1706        let default_labels = db.list_labels("default").unwrap();
1707        assert_eq!(default_labels.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
1708                   vec!["Apple", "Zebra"]);
1709        let work_labels = db.list_labels("work").unwrap();
1710        assert_eq!(work_labels.len(), 1);
1711        assert_eq!(work_labels[0].name, "OnlyInWork");
1712    }
1713
1714    #[test]
1715    fn delete_labels_for_workspace_not_in_removes_orphans() {
1716        use super::test_helpers::{test_db, make_label};
1717        let (db, _dir) = test_db();
1718
1719        db.upsert_label(&make_label("keep", "Keep", "default")).unwrap();
1720        db.upsert_label(&make_label("drop", "Drop", "default")).unwrap();
1721
1722        let kept = db.delete_labels_for_workspace_not_in("default", &["keep".to_string()]).unwrap();
1723        assert_eq!(kept, 1, "should report 1 deleted");
1724
1725        let listed = db.list_labels("default").unwrap();
1726        assert_eq!(listed.len(), 1);
1727        assert_eq!(listed[0].name, "Keep");
1728    }
1729
1730    #[test]
1731    fn replace_issue_labels_overwrites_existing() {
1732        use super::test_helpers::{test_db, make_issue, make_label};
1733        let (db, _dir) = test_db();
1734
1735        let issue = make_issue("ENG-1", "ENG");
1736        db.upsert_issue(&issue).unwrap();
1737        db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1738        db.upsert_label(&make_label("l2", "UI", "default")).unwrap();
1739        db.upsert_label(&make_label("l3", "Backend", "default")).unwrap();
1740
1741        db.replace_issue_labels(&issue.id, &["l1".to_string(), "l2".to_string()]).unwrap();
1742        let labels = db.get_issue_label_ids(&issue.id).unwrap();
1743        assert_eq!(labels, vec!["l1".to_string(), "l2".to_string()]);
1744
1745        // Replace overwrites
1746        db.replace_issue_labels(&issue.id, &["l3".to_string()]).unwrap();
1747        let labels = db.get_issue_label_ids(&issue.id).unwrap();
1748        assert_eq!(labels, vec!["l3".to_string()]);
1749    }
1750
1751    #[test]
1752    fn deleting_issue_cascades_to_issue_labels() {
1753        use super::test_helpers::{test_db, make_issue, make_label};
1754        let (db, _dir) = test_db();
1755
1756        let issue = make_issue("ENG-2", "ENG");
1757        db.upsert_issue(&issue).unwrap();
1758        db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1759        db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1760
1761        db.with_conn(|conn| {
1762            conn.execute("DELETE FROM issues WHERE id = ?1", rusqlite::params![&issue.id])?;
1763            let n: i64 = conn.query_row(
1764                "SELECT COUNT(*) FROM issue_labels WHERE issue_id = ?1",
1765                rusqlite::params![&issue.id], |r| r.get(0))?;
1766            assert_eq!(n, 0);
1767            Ok(())
1768        }).unwrap();
1769    }
1770
1771    #[test]
1772    fn deleting_label_cascades_to_issue_labels() {
1773        use super::test_helpers::{test_db, make_issue, make_label};
1774        let (db, _dir) = test_db();
1775
1776        let issue = make_issue("ENG-3", "ENG");
1777        db.upsert_issue(&issue).unwrap();
1778        db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1779        db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1780
1781        db.delete_labels_for_workspace_not_in("default", &[]).unwrap();
1782        let labels = db.get_issue_label_ids(&issue.id).unwrap();
1783        assert!(labels.is_empty());
1784    }
1785
1786    #[test]
1787    fn resolve_label_ids_local_matches_case_insensitive_and_returns_unknowns() {
1788        use super::test_helpers::{test_db, make_label};
1789        let (db, _dir) = test_db();
1790
1791        db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
1792        db.upsert_label(&make_label("l2", "Security", "default")).unwrap();
1793
1794        let (resolved, unknown) = db
1795            .resolve_label_ids_local("default", &["vanta".to_string(), "secURity".to_string(), "missing".to_string()])
1796            .unwrap();
1797        assert_eq!(resolved.len(), 2);
1798        assert!(resolved.contains(&"l1".to_string()));
1799        assert!(resolved.contains(&"l2".to_string()));
1800        assert_eq!(unknown, vec!["missing".to_string()]);
1801    }
1802
1803    #[test]
1804    fn resolve_label_ids_local_is_workspace_scoped() {
1805        use super::test_helpers::{test_db, make_label};
1806        let (db, _dir) = test_db();
1807        db.upsert_workspace("work", None, None).unwrap();
1808
1809        db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
1810        db.upsert_label(&make_label("l2", "Vanta", "work")).unwrap();
1811
1812        let (resolved, _) = db.resolve_label_ids_local("work", &["vanta".to_string()]).unwrap();
1813        assert_eq!(resolved, vec!["l2".to_string()]);
1814    }
1815
1816    #[test]
1817    fn get_unprioritized_issues_filters_by_labels_with_and_semantics() {
1818        use super::test_helpers::{test_db, make_issue, make_label};
1819        let (db, _dir) = test_db();
1820
1821        let mut a = make_issue("ENG-10", "ENG"); a.priority = 0;
1822        let mut b = make_issue("ENG-11", "ENG"); b.priority = 0;
1823        let mut c = make_issue("ENG-12", "ENG"); c.priority = 0;
1824        db.upsert_issue(&a).unwrap();
1825        db.upsert_issue(&b).unwrap();
1826        db.upsert_issue(&c).unwrap();
1827
1828        db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
1829        db.upsert_label(&make_label("sec",   "Security", "default")).unwrap();
1830
1831        db.replace_issue_labels(&a.id, &["vanta".to_string(), "sec".to_string()]).unwrap();
1832        db.replace_issue_labels(&b.id, &["vanta".to_string()]).unwrap();
1833        db.replace_issue_labels(&c.id, &["sec".to_string()]).unwrap();
1834
1835        // Filter by both labels (AND) → only `a`
1836        let result = db.get_unprioritized_issues_filtered(
1837            Some("ENG"), false, "default",
1838            Some(&["vanta".to_string(), "sec".to_string()]),
1839        ).unwrap();
1840        let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
1841        assert_eq!(idents, vec!["ENG-10"]);
1842
1843        // Filter by single label → `a` and `b`
1844        let result = db.get_unprioritized_issues_filtered(
1845            Some("ENG"), false, "default",
1846            Some(&["vanta".to_string()]),
1847        ).unwrap();
1848        let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
1849        assert!(idents.contains(&"ENG-10"));
1850        assert!(idents.contains(&"ENG-11"));
1851        assert!(!idents.contains(&"ENG-12"));
1852
1853        // No filter → all three
1854        let result = db.get_unprioritized_issues_filtered(Some("ENG"), false, "default", None).unwrap();
1855        assert_eq!(result.len(), 3);
1856    }
1857
1858    #[test]
1859    fn fts_search_with_label_filter_intersects() {
1860        use super::test_helpers::{test_db, make_issue, make_label};
1861        let (db, _dir) = test_db();
1862
1863        let mut a = make_issue("ENG-20", "ENG");
1864        a.title = "Audit logging gap".to_string();
1865        let mut b = make_issue("ENG-21", "ENG");
1866        b.title = "Audit something else".to_string();
1867        db.upsert_issue(&a).unwrap();
1868        db.upsert_issue(&b).unwrap();
1869
1870        db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
1871        db.replace_issue_labels(&a.id, &["vanta".to_string()]).unwrap();
1872
1873        // Without filter, both match "audit"
1874        let r = db.fts_search_filtered("\"audit\"", 10, "default", None).unwrap();
1875        assert_eq!(r.len(), 2);
1876
1877        // With Vanta filter, only `a`
1878        let r = db.fts_search_filtered("\"audit\"", 10, "default", Some(&["vanta".to_string()])).unwrap();
1879        assert_eq!(r.len(), 1);
1880        assert_eq!(r[0].identifier, "ENG-20");
1881    }
1882
1883    #[test]
1884    fn delete_workspace_cleans_up_labels() {
1885        use super::test_helpers::{test_db, make_label};
1886        let (db, _dir) = test_db();
1887        db.upsert_workspace("doomed", None, None).unwrap();
1888        db.upsert_label(&make_label("l1", "Lab", "doomed")).unwrap();
1889        db.delete_workspace("doomed").unwrap();
1890        let listed = db.list_labels("doomed").unwrap();
1891        assert!(listed.is_empty());
1892    }
1893}