Skip to main content

rectilinear_core/db/
projects.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use super::{Database, Issue};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct ProjectTeam {
8    pub id: String,
9    pub key: String,
10    pub name: String,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct ProjectMember {
15    pub id: String,
16    pub name: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct ProjectLabel {
21    pub id: String,
22    pub name: String,
23    pub color: String,
24    pub description: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct Project {
29    pub id: String,
30    pub workspace_id: String,
31    pub slug_id: String,
32    pub name: String,
33    pub description: String,
34    pub content: Option<String>,
35    pub icon: Option<String>,
36    pub color: String,
37    pub status_id: String,
38    pub status_name: String,
39    pub status_type: String,
40    pub status_color: String,
41    pub priority: i32,
42    pub start_date: Option<String>,
43    pub target_date: Option<String>,
44    pub lead_id: Option<String>,
45    pub lead_name: Option<String>,
46    pub created_at: String,
47    pub updated_at: String,
48    pub archived_at: Option<String>,
49    pub url: String,
50    pub progress: f64,
51    pub synced_at: Option<String>,
52    pub teams: Vec<ProjectTeam>,
53    pub members: Vec<ProjectMember>,
54    pub labels: Vec<ProjectLabel>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct ProjectMilestone {
59    pub id: String,
60    pub workspace_id: String,
61    pub project_id: String,
62    pub project_name: String,
63    pub name: String,
64    pub description: Option<String>,
65    pub target_date: Option<String>,
66    pub status: String,
67    pub progress: f64,
68    pub sort_order: f64,
69    pub created_at: String,
70    pub updated_at: String,
71    pub archived_at: Option<String>,
72    pub synced_at: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ProjectBundle {
77    pub project: Project,
78    pub milestones: Vec<ProjectMilestone>,
79    pub issues: Vec<Issue>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProjectMilestoneBundle {
84    pub project: Project,
85    pub milestone: ProjectMilestone,
86    pub issues: Vec<Issue>,
87}
88
89const PROJECT_COLUMNS: &str =
90    "id, workspace_id, slug_id, name, description, content, icon, color, \
91     status_id, status_name, status_type, status_color, priority, start_date, \
92     target_date, lead_id, lead_name, created_at, updated_at, archived_at, url, \
93     progress, synced_at";
94
95const ISSUE_COLUMNS: &str =
96    "id, identifier, team_key, title, description, state_name, state_type, \
97     priority, assignee_name, project_name, labels_json, created_at, updated_at, \
98     content_hash, synced_at, url, branch_name, workspace_id, project_id, \
99     project_milestone_id, project_milestone_name";
100
101impl Database {
102    pub fn upsert_project(&self, project: &Project) -> Result<()> {
103        self.with_conn(|conn| {
104            let tx = conn.unchecked_transaction()?;
105            tx.execute(
106                "INSERT OR IGNORE INTO workspaces (id) VALUES (?1)",
107                rusqlite::params![project.workspace_id],
108            )?;
109            tx.execute(
110                "INSERT INTO projects (
111                    id, workspace_id, slug_id, name, description, content, icon, color,
112                    status_id, status_name, status_type, status_color, priority,
113                    start_date, target_date, lead_id, lead_name, created_at, updated_at,
114                    archived_at, url, progress, synced_at
115                 ) VALUES (
116                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
117                    ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, datetime('now')
118                 ) ON CONFLICT(id) DO UPDATE SET
119                    workspace_id=excluded.workspace_id, slug_id=excluded.slug_id,
120                    name=excluded.name, description=excluded.description,
121                    content=excluded.content, icon=excluded.icon, color=excluded.color,
122                    status_id=excluded.status_id, status_name=excluded.status_name,
123                    status_type=excluded.status_type, status_color=excluded.status_color,
124                    priority=excluded.priority, start_date=excluded.start_date,
125                    target_date=excluded.target_date, lead_id=excluded.lead_id,
126                    lead_name=excluded.lead_name, created_at=excluded.created_at,
127                    updated_at=excluded.updated_at, archived_at=excluded.archived_at,
128                    url=excluded.url, progress=excluded.progress, synced_at=datetime('now')",
129                rusqlite::params![
130                    project.id,
131                    project.workspace_id,
132                    project.slug_id,
133                    project.name,
134                    project.description,
135                    project.content,
136                    project.icon,
137                    project.color,
138                    project.status_id,
139                    project.status_name,
140                    project.status_type,
141                    project.status_color,
142                    project.priority,
143                    project.start_date,
144                    project.target_date,
145                    project.lead_id,
146                    project.lead_name,
147                    project.created_at,
148                    project.updated_at,
149                    project.archived_at,
150                    project.url,
151                    project.progress,
152                ],
153            )?;
154
155            tx.execute(
156                "DELETE FROM project_teams WHERE project_id = ?1",
157                rusqlite::params![project.id],
158            )?;
159            for team in &project.teams {
160                tx.execute(
161                    "INSERT INTO project_teams (project_id, team_id, team_key, team_name)
162                     VALUES (?1, ?2, ?3, ?4)",
163                    rusqlite::params![project.id, team.id, team.key, team.name],
164                )?;
165            }
166
167            tx.execute(
168                "DELETE FROM project_members WHERE project_id = ?1",
169                rusqlite::params![project.id],
170            )?;
171            for member in &project.members {
172                tx.execute(
173                    "INSERT INTO project_members (project_id, user_id, user_name)
174                     VALUES (?1, ?2, ?3)",
175                    rusqlite::params![project.id, member.id, member.name],
176                )?;
177            }
178
179            tx.execute(
180                "DELETE FROM project_labels WHERE project_id = ?1",
181                rusqlite::params![project.id],
182            )?;
183            for label in &project.labels {
184                tx.execute(
185                    "INSERT INTO project_labels (
186                        project_id, label_id, label_name, color, description
187                     ) VALUES (?1, ?2, ?3, ?4, ?5)",
188                    rusqlite::params![
189                        project.id,
190                        label.id,
191                        label.name,
192                        label.color,
193                        label.description,
194                    ],
195                )?;
196            }
197            tx.commit()?;
198            Ok(())
199        })
200    }
201
202    pub fn upsert_project_milestone(&self, milestone: &ProjectMilestone) -> Result<()> {
203        self.with_conn(|conn| {
204            conn.execute(
205                "INSERT INTO project_milestones (
206                    id, workspace_id, project_id, name, description, target_date,
207                    status, progress, sort_order, created_at, updated_at, archived_at, synced_at
208                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, datetime('now'))
209                 ON CONFLICT(id) DO UPDATE SET
210                    workspace_id=excluded.workspace_id, project_id=excluded.project_id,
211                    name=excluded.name, description=excluded.description,
212                    target_date=excluded.target_date, status=excluded.status,
213                    progress=excluded.progress, sort_order=excluded.sort_order,
214                    created_at=excluded.created_at, updated_at=excluded.updated_at,
215                    archived_at=excluded.archived_at, synced_at=datetime('now')",
216                rusqlite::params![
217                    milestone.id,
218                    milestone.workspace_id,
219                    milestone.project_id,
220                    milestone.name,
221                    milestone.description,
222                    milestone.target_date,
223                    milestone.status,
224                    milestone.progress,
225                    milestone.sort_order,
226                    milestone.created_at,
227                    milestone.updated_at,
228                    milestone.archived_at,
229                ],
230            )?;
231            Ok(())
232        })
233    }
234
235    pub fn get_project(&self, workspace_id: &str, id_or_name: &str) -> Result<Option<Project>> {
236        self.with_conn(|conn| {
237            let sql = format!(
238                "SELECT {PROJECT_COLUMNS} FROM projects
239                 WHERE workspace_id = ?1 AND (id = ?2 OR slug_id = ?2 OR name = ?2 COLLATE NOCASE)
240                 ORDER BY CASE WHEN id = ?2 OR slug_id = ?2 THEN 0 ELSE 1 END, updated_at DESC
241                 LIMIT 1"
242            );
243            let mut stmt = conn.prepare(&sql)?;
244            let mut rows = stmt.query(rusqlite::params![workspace_id, id_or_name])?;
245            Ok(rows
246                .next()?
247                .map(|row| project_from_row(conn, row))
248                .transpose()?)
249        })
250    }
251
252    pub fn list_projects(
253        &self,
254        workspace_id: &str,
255        include_archived: bool,
256    ) -> Result<Vec<Project>> {
257        self.with_conn(|conn| {
258            let archive_filter = if include_archived {
259                ""
260            } else {
261                "AND archived_at IS NULL"
262            };
263            let sql = format!(
264                "SELECT {PROJECT_COLUMNS} FROM projects
265                 WHERE workspace_id = ?1 {archive_filter}
266                 ORDER BY updated_at DESC, name COLLATE NOCASE"
267            );
268            let mut stmt = conn.prepare(&sql)?;
269            let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
270                project_from_row(conn, row)
271            })?;
272            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
273        })
274    }
275
276    pub fn get_project_milestone(
277        &self,
278        workspace_id: &str,
279        id_or_name: &str,
280        project_id: Option<&str>,
281    ) -> Result<Option<ProjectMilestone>> {
282        self.with_conn(|conn| {
283            let project_filter = if project_id.is_some() {
284                "AND pm.project_id = ?3"
285            } else {
286                ""
287            };
288            let sql = format!(
289                "SELECT pm.id, pm.workspace_id, pm.project_id, p.name, pm.name,
290                        pm.description, pm.target_date, pm.status, pm.progress,
291                        pm.sort_order, pm.created_at, pm.updated_at, pm.archived_at, pm.synced_at
292                 FROM project_milestones pm
293                 JOIN projects p ON p.id = pm.project_id
294                 WHERE pm.workspace_id = ?1
295                   AND (pm.id = ?2 OR pm.name = ?2 COLLATE NOCASE)
296                   {project_filter}
297                 ORDER BY CASE WHEN pm.id = ?2 THEN 0 ELSE 1 END, pm.updated_at DESC
298                 LIMIT 1"
299            );
300            let mut stmt = conn.prepare(&sql)?;
301            let mut rows = if let Some(pid) = project_id {
302                stmt.query(rusqlite::params![workspace_id, id_or_name, pid])?
303            } else {
304                stmt.query(rusqlite::params![workspace_id, id_or_name])?
305            };
306            Ok(rows.next()?.map(milestone_from_row).transpose()?)
307        })
308    }
309
310    pub fn list_project_milestones(&self, project_id: &str) -> Result<Vec<ProjectMilestone>> {
311        self.with_conn(|conn| {
312            let mut stmt = conn.prepare(
313                "SELECT pm.id, pm.workspace_id, pm.project_id, p.name, pm.name,
314                        pm.description, pm.target_date, pm.status, pm.progress,
315                        pm.sort_order, pm.created_at, pm.updated_at, pm.archived_at, pm.synced_at
316                 FROM project_milestones pm
317                 JOIN projects p ON p.id = pm.project_id
318                 WHERE pm.project_id = ?1
319                 ORDER BY pm.sort_order, pm.target_date, pm.created_at",
320            )?;
321            let rows = stmt.query_map(rusqlite::params![project_id], milestone_from_row)?;
322            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
323        })
324    }
325
326    pub fn get_project_bundle(
327        &self,
328        workspace_id: &str,
329        id_or_name: &str,
330    ) -> Result<Option<ProjectBundle>> {
331        let Some(project) = self.get_project(workspace_id, id_or_name)? else {
332            return Ok(None);
333        };
334        let milestones = self.list_project_milestones(&project.id)?;
335        let issues = self.list_project_issues(workspace_id, &project.id)?;
336        Ok(Some(ProjectBundle {
337            project,
338            milestones,
339            issues,
340        }))
341    }
342
343    pub fn get_project_milestone_bundle(
344        &self,
345        workspace_id: &str,
346        id_or_name: &str,
347        project_id: Option<&str>,
348    ) -> Result<Option<ProjectMilestoneBundle>> {
349        let Some(milestone) = self.get_project_milestone(workspace_id, id_or_name, project_id)?
350        else {
351            return Ok(None);
352        };
353        let Some(project) = self.get_project(workspace_id, &milestone.project_id)? else {
354            return Ok(None);
355        };
356        let issues = self.list_project_milestone_issues(workspace_id, &milestone.id)?;
357        Ok(Some(ProjectMilestoneBundle {
358            project,
359            milestone,
360            issues,
361        }))
362    }
363
364    pub fn list_project_issues(&self, workspace_id: &str, project_id: &str) -> Result<Vec<Issue>> {
365        self.list_issues_for_hierarchy(workspace_id, "project_id", project_id)
366    }
367
368    pub fn list_project_milestone_issues(
369        &self,
370        workspace_id: &str,
371        milestone_id: &str,
372    ) -> Result<Vec<Issue>> {
373        self.list_issues_for_hierarchy(workspace_id, "project_milestone_id", milestone_id)
374    }
375
376    pub fn reconcile_project_issue_membership(
377        &self,
378        workspace_id: &str,
379        project_id: &str,
380        keep_issue_ids: &[String],
381    ) -> Result<usize> {
382        self.clear_issue_membership_not_in(
383            workspace_id,
384            "project_id",
385            project_id,
386            "project_id = NULL, project_name = NULL, project_milestone_id = NULL, \
387             project_milestone_name = NULL",
388            keep_issue_ids,
389        )
390    }
391
392    pub fn reconcile_project_milestone_issue_membership(
393        &self,
394        workspace_id: &str,
395        milestone_id: &str,
396        keep_issue_ids: &[String],
397    ) -> Result<usize> {
398        self.clear_issue_membership_not_in(
399            workspace_id,
400            "project_milestone_id",
401            milestone_id,
402            "project_milestone_id = NULL, project_milestone_name = NULL",
403            keep_issue_ids,
404        )
405    }
406
407    fn list_issues_for_hierarchy(
408        &self,
409        workspace_id: &str,
410        column: &str,
411        value: &str,
412    ) -> Result<Vec<Issue>> {
413        debug_assert!(matches!(column, "project_id" | "project_milestone_id"));
414        self.with_conn(|conn| {
415            let sql = format!(
416                "SELECT {ISSUE_COLUMNS} FROM issues
417                 WHERE workspace_id = ?1 AND {column} = ?2
418                 ORDER BY team_key, identifier"
419            );
420            let mut stmt = conn.prepare(&sql)?;
421            let rows = stmt.query_map(rusqlite::params![workspace_id, value], Issue::from_row)?;
422            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
423        })
424    }
425
426    fn clear_issue_membership_not_in(
427        &self,
428        workspace_id: &str,
429        hierarchy_column: &str,
430        hierarchy_id: &str,
431        assignments: &str,
432        keep_issue_ids: &[String],
433    ) -> Result<usize> {
434        debug_assert!(matches!(
435            hierarchy_column,
436            "project_id" | "project_milestone_id"
437        ));
438        self.with_conn(|conn| {
439            let exclusion = if keep_issue_ids.is_empty() {
440                String::new()
441            } else {
442                let placeholders = (0..keep_issue_ids.len())
443                    .map(|index| format!("?{}", index + 3))
444                    .collect::<Vec<_>>()
445                    .join(", ");
446                format!("AND id NOT IN ({placeholders})")
447            };
448            let sql = format!(
449                "UPDATE issues SET {assignments}, synced_at = datetime('now')
450                 WHERE workspace_id = ?1 AND {hierarchy_column} = ?2 {exclusion}"
451            );
452            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
453                Box::new(workspace_id.to_string()),
454                Box::new(hierarchy_id.to_string()),
455            ];
456            params.extend(
457                keep_issue_ids
458                    .iter()
459                    .cloned()
460                    .map(|id| Box::new(id) as Box<dyn rusqlite::types::ToSql>),
461            );
462            let refs = params
463                .iter()
464                .map(|value| value.as_ref())
465                .collect::<Vec<_>>();
466            Ok(conn.execute(&sql, refs.as_slice())?)
467        })
468    }
469
470    pub fn delete_project_local(&self, project_id: &str) -> Result<usize> {
471        self.with_conn(|conn| {
472            let tx = conn.unchecked_transaction()?;
473            tx.execute(
474                "UPDATE issues SET project_id = NULL, project_name = NULL,
475                        project_milestone_id = NULL, project_milestone_name = NULL,
476                        synced_at = datetime('now')
477                 WHERE project_id = ?1",
478                rusqlite::params![project_id],
479            )?;
480            let changed = tx.execute(
481                "DELETE FROM projects WHERE id = ?1",
482                rusqlite::params![project_id],
483            )?;
484            tx.commit()?;
485            Ok(changed)
486        })
487    }
488
489    pub fn delete_project_milestone_local(&self, milestone_id: &str) -> Result<usize> {
490        self.with_conn(|conn| {
491            let tx = conn.unchecked_transaction()?;
492            tx.execute(
493                "UPDATE issues SET project_milestone_id = NULL,
494                        project_milestone_name = NULL, synced_at = datetime('now')
495                 WHERE project_milestone_id = ?1",
496                rusqlite::params![milestone_id],
497            )?;
498            let changed = tx.execute(
499                "DELETE FROM project_milestones WHERE id = ?1",
500                rusqlite::params![milestone_id],
501            )?;
502            tx.commit()?;
503            Ok(changed)
504        })
505    }
506
507    pub fn delete_projects_for_workspace_not_in(
508        &self,
509        workspace_id: &str,
510        keep_ids: &[String],
511    ) -> Result<usize> {
512        self.delete_hierarchy_not_in("projects", "workspace_id", workspace_id, keep_ids)
513    }
514
515    pub fn delete_milestones_for_workspace_not_in(
516        &self,
517        workspace_id: &str,
518        keep_ids: &[String],
519    ) -> Result<usize> {
520        self.delete_hierarchy_not_in("project_milestones", "workspace_id", workspace_id, keep_ids)
521    }
522
523    pub fn delete_milestones_for_project_not_in(
524        &self,
525        project_id: &str,
526        keep_ids: &[String],
527    ) -> Result<usize> {
528        self.delete_hierarchy_not_in("project_milestones", "project_id", project_id, keep_ids)
529    }
530
531    fn delete_hierarchy_not_in(
532        &self,
533        table: &str,
534        scope_column: &str,
535        scope: &str,
536        keep_ids: &[String],
537    ) -> Result<usize> {
538        debug_assert!(matches!(table, "projects" | "project_milestones"));
539        debug_assert!(matches!(scope_column, "workspace_id" | "project_id"));
540        self.with_conn(|conn| {
541            let exclusion = if keep_ids.is_empty() {
542                String::new()
543            } else {
544                let placeholders = (0..keep_ids.len())
545                    .map(|index| format!("?{}", index + 2))
546                    .collect::<Vec<_>>()
547                    .join(", ");
548                format!("AND id NOT IN ({placeholders})")
549            };
550            let stale_filter = format!("{scope_column} = ?1 {exclusion}");
551            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
552                vec![Box::new(scope.to_string())];
553            params.extend(
554                keep_ids
555                    .iter()
556                    .cloned()
557                    .map(|id| Box::new(id) as Box<dyn rusqlite::types::ToSql>),
558            );
559            let refs = params
560                .iter()
561                .map(|value| value.as_ref())
562                .collect::<Vec<_>>();
563            let issue_update = if table == "projects" {
564                format!(
565                    "UPDATE issues SET project_id = NULL, project_name = NULL,
566                            project_milestone_id = NULL, project_milestone_name = NULL,
567                            synced_at = datetime('now')
568                     WHERE project_id IN (SELECT id FROM projects WHERE {stale_filter})"
569                )
570            } else {
571                format!(
572                    "UPDATE issues SET project_milestone_id = NULL,
573                            project_milestone_name = NULL, synced_at = datetime('now')
574                     WHERE project_milestone_id IN (
575                         SELECT id FROM project_milestones WHERE {stale_filter}
576                     )"
577                )
578            };
579            conn.execute(&issue_update, refs.as_slice())?;
580            let sql = format!("DELETE FROM {table} WHERE {stale_filter}");
581            Ok(conn.execute(&sql, refs.as_slice())?)
582        })
583    }
584}
585
586fn project_from_row(conn: &rusqlite::Connection, row: &rusqlite::Row) -> rusqlite::Result<Project> {
587    let project_id: String = row.get(0)?;
588    let teams = {
589        let mut stmt = conn.prepare(
590            "SELECT team_id, team_key, team_name FROM project_teams
591             WHERE project_id = ?1 ORDER BY team_key",
592        )?;
593        let rows = stmt.query_map(rusqlite::params![project_id], |team_row| {
594            Ok(ProjectTeam {
595                id: team_row.get(0)?,
596                key: team_row.get(1)?,
597                name: team_row.get(2)?,
598            })
599        })?;
600        rows.collect::<std::result::Result<Vec<_>, _>>()?
601    };
602    let members = {
603        let mut stmt = conn.prepare(
604            "SELECT user_id, user_name FROM project_members
605             WHERE project_id = ?1 ORDER BY user_name COLLATE NOCASE",
606        )?;
607        let rows = stmt.query_map(rusqlite::params![project_id], |member_row| {
608            Ok(ProjectMember {
609                id: member_row.get(0)?,
610                name: member_row.get(1)?,
611            })
612        })?;
613        rows.collect::<std::result::Result<Vec<_>, _>>()?
614    };
615    let labels = {
616        let mut stmt = conn.prepare(
617            "SELECT label_id, label_name, color, description FROM project_labels
618             WHERE project_id = ?1 ORDER BY label_name COLLATE NOCASE",
619        )?;
620        let rows = stmt.query_map(rusqlite::params![project_id], |label_row| {
621            Ok(ProjectLabel {
622                id: label_row.get(0)?,
623                name: label_row.get(1)?,
624                color: label_row.get(2)?,
625                description: label_row.get(3)?,
626            })
627        })?;
628        rows.collect::<std::result::Result<Vec<_>, _>>()?
629    };
630    Ok(Project {
631        id: project_id,
632        workspace_id: row.get(1)?,
633        slug_id: row.get(2)?,
634        name: row.get(3)?,
635        description: row.get(4)?,
636        content: row.get(5)?,
637        icon: row.get(6)?,
638        color: row.get(7)?,
639        status_id: row.get(8)?,
640        status_name: row.get(9)?,
641        status_type: row.get(10)?,
642        status_color: row.get(11)?,
643        priority: row.get(12)?,
644        start_date: row.get(13)?,
645        target_date: row.get(14)?,
646        lead_id: row.get(15)?,
647        lead_name: row.get(16)?,
648        created_at: row.get(17)?,
649        updated_at: row.get(18)?,
650        archived_at: row.get(19)?,
651        url: row.get(20)?,
652        progress: row.get(21)?,
653        synced_at: row.get(22)?,
654        teams,
655        members,
656        labels,
657    })
658}
659
660fn milestone_from_row(row: &rusqlite::Row) -> rusqlite::Result<ProjectMilestone> {
661    Ok(ProjectMilestone {
662        id: row.get(0)?,
663        workspace_id: row.get(1)?,
664        project_id: row.get(2)?,
665        project_name: row.get(3)?,
666        name: row.get(4)?,
667        description: row.get(5)?,
668        target_date: row.get(6)?,
669        status: row.get(7)?,
670        progress: row.get(8)?,
671        sort_order: row.get(9)?,
672        created_at: row.get(10)?,
673        updated_at: row.get(11)?,
674        archived_at: row.get(12)?,
675        synced_at: row.get(13)?,
676    })
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::db::test_helpers::{make_issue, test_db};
683
684    fn project() -> Project {
685        Project {
686            id: "project-1".into(),
687            workspace_id: "default".into(),
688            slug_id: "api-reliability".into(),
689            name: "API Reliability".into(),
690            description: "Service resilience".into(),
691            content: Some("Detailed rollout plan".into()),
692            icon: Some("Cube".into()),
693            color: "#f2994a".into(),
694            status_id: "status-1".into(),
695            status_name: "Backlog".into(),
696            status_type: "backlog".into(),
697            status_color: "#888888".into(),
698            priority: 2,
699            start_date: None,
700            target_date: Some("2026-09-01".into()),
701            lead_id: Some("user-1".into()),
702            lead_name: Some("Alex Morgan".into()),
703            created_at: "2026-07-01T00:00:00Z".into(),
704            updated_at: "2026-07-16T00:00:00Z".into(),
705            archived_at: None,
706            url: "https://linear.app/acme/project/api-reliability".into(),
707            progress: 0.25,
708            synced_at: None,
709            teams: vec![ProjectTeam {
710                id: "team-1".into(),
711                key: "ENG".into(),
712                name: "Engineering".into(),
713            }],
714            members: vec![ProjectMember {
715                id: "user-1".into(),
716                name: "Alex Morgan".into(),
717            }],
718            labels: vec![ProjectLabel {
719                id: "label-1".into(),
720                name: "Infrastructure".into(),
721                color: "#f2994a".into(),
722                description: Some("Platform engineering".into()),
723            }],
724        }
725    }
726
727    fn milestone() -> ProjectMilestone {
728        ProjectMilestone {
729            id: "milestone-1".into(),
730            workspace_id: "default".into(),
731            project_id: "project-1".into(),
732            project_name: "API Reliability".into(),
733            name: "Request tracing".into(),
734            description: Some("Instrument critical request paths".into()),
735            target_date: Some("2026-08-15".into()),
736            status: "next".into(),
737            progress: 0.5,
738            sort_order: 1.0,
739            created_at: "2026-07-01T00:00:00Z".into(),
740            updated_at: "2026-07-16T00:00:00Z".into(),
741            archived_at: None,
742            synced_at: None,
743        }
744    }
745
746    #[test]
747    fn project_round_trip_preserves_metadata_and_relationships() {
748        let (db, _dir) = test_db();
749        db.upsert_project(&project()).unwrap();
750        db.upsert_project_milestone(&milestone()).unwrap();
751
752        let stored = db
753            .get_project("default", "api-reliability")
754            .unwrap()
755            .unwrap();
756        assert_eq!(stored.name, "API Reliability");
757        assert_eq!(stored.status_name, "Backlog");
758        assert_eq!(stored.teams[0].key, "ENG");
759        assert_eq!(stored.members[0].name, "Alex Morgan");
760        assert_eq!(stored.labels[0].name, "Infrastructure");
761
762        let milestones = db.list_project_milestones(&stored.id).unwrap();
763        assert_eq!(milestones.len(), 1);
764        assert_eq!(milestones[0].name, "Request tracing");
765        assert!(milestones[0].synced_at.is_some());
766    }
767
768    #[test]
769    fn project_and_milestone_bundles_include_linked_issues() {
770        let (db, _dir) = test_db();
771        db.upsert_project(&project()).unwrap();
772        db.upsert_project_milestone(&milestone()).unwrap();
773        let mut issue = make_issue("ENG-1", "ENG");
774        issue.priority = 0;
775        issue.project_id = Some("project-1".into());
776        issue.project_milestone_id = Some("milestone-1".into());
777        issue.project_milestone_name = Some("Request tracing".into());
778        db.upsert_issue(&issue).unwrap();
779
780        let bundle = db
781            .get_project_bundle("default", "API Reliability")
782            .unwrap()
783            .unwrap();
784        assert_eq!(bundle.milestones.len(), 1);
785        assert_eq!(bundle.issues[0].identifier, "ENG-1");
786
787        let milestone_bundle = db
788            .get_project_milestone_bundle("default", "Request tracing", Some("project-1"))
789            .unwrap()
790            .unwrap();
791        assert_eq!(
792            milestone_bundle.issues[0].project_milestone_name.as_deref(),
793            Some("Request tracing")
794        );
795
796        let triage_issues = db.get_unprioritized_issues(None, false, "default").unwrap();
797        assert_eq!(triage_issues[0].project_id.as_deref(), Some("project-1"));
798        assert_eq!(
799            triage_issues[0].project_milestone_id.as_deref(),
800            Some("milestone-1")
801        );
802    }
803
804    #[test]
805    fn deleting_project_cascades_milestones_but_not_issues() {
806        let (db, _dir) = test_db();
807        db.upsert_project(&project()).unwrap();
808        db.upsert_project_milestone(&milestone()).unwrap();
809        let mut issue = make_issue("ENG-1", "ENG");
810        issue.project_id = Some("project-1".into());
811        issue.project_milestone_id = Some("milestone-1".into());
812        db.upsert_issue(&issue).unwrap();
813
814        assert_eq!(db.delete_project_local("project-1").unwrap(), 1);
815        assert!(db.list_project_milestones("project-1").unwrap().is_empty());
816        let issue = db.get_issue("ENG-1").unwrap().unwrap();
817        assert!(issue.project_id.is_none());
818        assert!(issue.project_milestone_id.is_none());
819    }
820
821    #[test]
822    fn deleting_milestone_clears_issue_milestone_but_keeps_project() {
823        let (db, _dir) = test_db();
824        db.upsert_project(&project()).unwrap();
825        db.upsert_project_milestone(&milestone()).unwrap();
826        let mut issue = make_issue("ENG-1", "ENG");
827        issue.project_id = Some("project-1".into());
828        issue.project_milestone_id = Some("milestone-1".into());
829        db.upsert_issue(&issue).unwrap();
830
831        assert_eq!(db.delete_project_milestone_local("milestone-1").unwrap(), 1);
832        let issue = db.get_issue("ENG-1").unwrap().unwrap();
833        assert_eq!(issue.project_id.as_deref(), Some("project-1"));
834        assert!(issue.project_milestone_id.is_none());
835    }
836
837    #[test]
838    fn hierarchy_reconciliation_clears_stale_issue_relationships() {
839        let (db, _dir) = test_db();
840        db.upsert_project(&project()).unwrap();
841        db.upsert_project_milestone(&milestone()).unwrap();
842        for identifier in ["ENG-1", "ENG-2"] {
843            let mut issue = make_issue(identifier, "ENG");
844            issue.project_id = Some("project-1".into());
845            issue.project_name = Some("API Reliability".into());
846            issue.project_milestone_id = Some("milestone-1".into());
847            issue.project_milestone_name = Some("Request tracing".into());
848            db.upsert_issue(&issue).unwrap();
849        }
850
851        db.reconcile_project_milestone_issue_membership(
852            "default",
853            "milestone-1",
854            &["ENG-1".into()],
855        )
856        .unwrap();
857        let stale = db.get_issue("ENG-2").unwrap().unwrap();
858        assert!(stale.project_id.is_some());
859        assert!(stale.project_milestone_id.is_none());
860
861        db.reconcile_project_issue_membership("default", "project-1", &["ENG-1".into()])
862            .unwrap();
863        let stale = db.get_issue("ENG-2").unwrap().unwrap();
864        assert!(stale.project_id.is_none());
865        assert!(stale.project_milestone_name.is_none());
866    }
867}