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