Skip to main content

rectilinear_core/db/
mod.rs

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