Skip to main content

rectilinear_core/db/
mod.rs

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