Skip to main content

rectilinear_core/
ffi.rs

1//! UniFFI facade — single entry point for Swift callers.
2//!
3//! Exposes a `RectilinearEngine` object with sync methods for database reads
4//! and async methods for network operations. All types crossing the FFI
5//! boundary use the `Rt` prefix to avoid collisions with Swift-side types.
6
7use crate::config::Config;
8use crate::db::Database;
9use crate::linear::LinearClient;
10use crate::search;
11use std::path::Path;
12use std::sync::Mutex;
13use tokio::sync::OnceCell;
14
15// ── Error ────────────────────────────────────────────────────────────
16
17#[derive(Debug, thiserror::Error, uniffi::Error)]
18pub enum RectilinearError {
19    #[error("Database error: {message}")]
20    Database { message: String },
21    #[error("API error: {message}")]
22    Api { message: String },
23    #[error("Config error: {message}")]
24    Config { message: String },
25    #[error("Not found: {key}")]
26    NotFound { key: String },
27}
28
29impl From<anyhow::Error> for RectilinearError {
30    fn from(err: anyhow::Error) -> Self {
31        RectilinearError::Database {
32            message: err.to_string(),
33        }
34    }
35}
36
37// ── FFI Records ──────────────────────────────────────────────────────
38
39#[derive(uniffi::Record)]
40pub struct RtIssue {
41    pub id: String,
42    pub identifier: String,
43    pub team_key: String,
44    pub title: String,
45    pub description: Option<String>,
46    pub state_name: String,
47    pub state_type: String,
48    pub priority: i32,
49    pub assignee_name: Option<String>,
50    pub project_name: Option<String>,
51    pub project_id: Option<String>,
52    pub project_milestone_id: Option<String>,
53    pub project_milestone_name: Option<String>,
54    pub labels: Vec<String>,
55    pub created_at: String,
56    pub updated_at: String,
57    pub url: String,
58    pub branch_name: Option<String>,
59}
60
61impl From<crate::db::Issue> for RtIssue {
62    fn from(issue: crate::db::Issue) -> Self {
63        let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
64        Self {
65            id: issue.id,
66            identifier: issue.identifier,
67            team_key: issue.team_key,
68            title: issue.title,
69            description: issue.description,
70            state_name: issue.state_name,
71            state_type: issue.state_type,
72            priority: issue.priority,
73            assignee_name: issue.assignee_name,
74            project_name: issue.project_name,
75            project_id: issue.project_id,
76            project_milestone_id: issue.project_milestone_id,
77            project_milestone_name: issue.project_milestone_name,
78            labels,
79            created_at: issue.created_at,
80            updated_at: issue.updated_at,
81            url: issue.url,
82            branch_name: issue.branch_name,
83        }
84    }
85}
86
87#[derive(uniffi::Record)]
88pub struct RtSearchResult {
89    pub issue_id: String,
90    pub identifier: String,
91    pub title: String,
92    pub state_name: String,
93    pub priority: i32,
94    pub score: f64,
95    pub similarity: Option<f32>,
96}
97
98impl From<search::SearchResult> for RtSearchResult {
99    fn from(sr: search::SearchResult) -> Self {
100        Self {
101            issue_id: sr.issue_id,
102            identifier: sr.identifier,
103            title: sr.title,
104            state_name: sr.state_name,
105            priority: sr.priority,
106            score: sr.score,
107            similarity: sr.similarity,
108        }
109    }
110}
111
112#[derive(uniffi::Record)]
113pub struct RtRelation {
114    pub relation_type: String,
115    pub issue_identifier: String,
116    pub issue_title: String,
117    pub issue_state: String,
118    pub issue_url: String,
119}
120
121impl From<crate::db::EnrichedRelation> for RtRelation {
122    fn from(rel: crate::db::EnrichedRelation) -> Self {
123        Self {
124            relation_type: rel.relation_type,
125            issue_identifier: rel.issue_identifier,
126            issue_title: rel.issue_title,
127            issue_state: rel.issue_state,
128            issue_url: rel.issue_url,
129        }
130    }
131}
132
133#[derive(uniffi::Record)]
134pub struct RtBlocker {
135    pub identifier: String,
136    pub title: String,
137    pub state_name: String,
138    pub is_terminal: bool,
139}
140
141#[derive(uniffi::Record)]
142pub struct RtIssueEnriched {
143    pub id: String,
144    pub identifier: String,
145    pub team_key: String,
146    pub title: String,
147    pub description: Option<String>,
148    pub state_name: String,
149    pub state_type: String,
150    pub priority: i32,
151    pub assignee_name: Option<String>,
152    pub project_name: Option<String>,
153    pub project_id: Option<String>,
154    pub project_milestone_id: Option<String>,
155    pub project_milestone_name: Option<String>,
156    pub labels: Vec<String>,
157    pub created_at: String,
158    pub updated_at: String,
159    pub url: String,
160    pub branch_name: Option<String>,
161    pub blocked_by: Vec<RtBlocker>,
162}
163
164#[derive(uniffi::Record)]
165pub struct RtTeam {
166    pub id: String,
167    pub key: String,
168    pub name: String,
169}
170
171#[derive(uniffi::Enum)]
172pub enum RtSearchMode {
173    Fts,
174    Vector,
175    Hybrid,
176}
177
178#[derive(uniffi::Record)]
179pub struct RtFieldCompleteness {
180    pub total: u64,
181    pub with_description: u64,
182    pub with_priority: u64,
183    pub with_labels: u64,
184    pub with_project: u64,
185}
186
187#[derive(uniffi::Record)]
188pub struct RtIssueSummary {
189    pub id: String,
190    pub identifier: String,
191    pub team_key: String,
192    pub title: String,
193    pub state_name: String,
194    pub state_type: String,
195    pub priority: i32,
196    pub project_name: Option<String>,
197    pub labels: Vec<String>,
198    pub updated_at: String,
199    pub url: String,
200    pub has_description: bool,
201    pub has_embedding: bool,
202}
203
204impl From<crate::db::IssueSummary> for RtIssueSummary {
205    fn from(s: crate::db::IssueSummary) -> Self {
206        Self {
207            id: s.id,
208            identifier: s.identifier,
209            team_key: s.team_key,
210            title: s.title,
211            state_name: s.state_name,
212            state_type: s.state_type,
213            priority: s.priority,
214            project_name: s.project_name,
215            labels: s.labels,
216            updated_at: s.updated_at,
217            url: s.url,
218            has_description: s.has_description,
219            has_embedding: s.has_embedding,
220        }
221    }
222}
223
224#[derive(uniffi::Record)]
225pub struct RtTeamSummary {
226    pub key: String,
227    pub issue_count: u64,
228    pub embedded_count: u64,
229    pub last_synced_at: Option<String>,
230}
231
232#[derive(uniffi::Record)]
233pub struct RtCreateIssueResult {
234    pub id: String,
235    pub identifier: String,
236}
237
238#[derive(uniffi::Record)]
239pub struct RtCreateIssueInput {
240    pub team_key: String,
241    pub title: String,
242    pub description: Option<String>,
243    pub priority: Option<i32>,
244    pub label_ids: Vec<String>,
245    pub parent_id: Option<String>,
246    pub project_id: Option<String>,
247    pub project_milestone_id: Option<String>,
248}
249
250#[derive(uniffi::Record)]
251pub struct RtProjectTeam {
252    pub id: String,
253    pub key: String,
254    pub name: String,
255}
256
257#[derive(uniffi::Record)]
258pub struct RtProjectMember {
259    pub id: String,
260    pub name: String,
261}
262
263#[derive(uniffi::Record)]
264pub struct RtProjectLabel {
265    pub id: String,
266    pub name: String,
267    pub color: String,
268    pub description: Option<String>,
269}
270
271#[derive(uniffi::Record)]
272pub struct RtProject {
273    pub id: String,
274    pub workspace_id: String,
275    pub slug_id: String,
276    pub name: String,
277    pub description: String,
278    pub content: Option<String>,
279    pub icon: Option<String>,
280    pub color: String,
281    pub status_id: String,
282    pub status_name: String,
283    pub status_type: String,
284    pub status_color: String,
285    pub priority: i32,
286    pub start_date: Option<String>,
287    pub target_date: Option<String>,
288    pub lead_id: Option<String>,
289    pub lead_name: Option<String>,
290    pub created_at: String,
291    pub updated_at: String,
292    pub archived_at: Option<String>,
293    pub url: String,
294    pub progress: f64,
295    pub teams: Vec<RtProjectTeam>,
296    pub members: Vec<RtProjectMember>,
297    pub labels: Vec<RtProjectLabel>,
298}
299
300impl From<crate::db::Project> for RtProject {
301    fn from(project: crate::db::Project) -> Self {
302        Self {
303            id: project.id,
304            workspace_id: project.workspace_id,
305            slug_id: project.slug_id,
306            name: project.name,
307            description: project.description,
308            content: project.content,
309            icon: project.icon,
310            color: project.color,
311            status_id: project.status_id,
312            status_name: project.status_name,
313            status_type: project.status_type,
314            status_color: project.status_color,
315            priority: project.priority,
316            start_date: project.start_date,
317            target_date: project.target_date,
318            lead_id: project.lead_id,
319            lead_name: project.lead_name,
320            created_at: project.created_at,
321            updated_at: project.updated_at,
322            archived_at: project.archived_at,
323            url: project.url,
324            progress: project.progress,
325            teams: project
326                .teams
327                .into_iter()
328                .map(|team| RtProjectTeam {
329                    id: team.id,
330                    key: team.key,
331                    name: team.name,
332                })
333                .collect(),
334            members: project
335                .members
336                .into_iter()
337                .map(|member| RtProjectMember {
338                    id: member.id,
339                    name: member.name,
340                })
341                .collect(),
342            labels: project
343                .labels
344                .into_iter()
345                .map(|label| RtProjectLabel {
346                    id: label.id,
347                    name: label.name,
348                    color: label.color,
349                    description: label.description,
350                })
351                .collect(),
352        }
353    }
354}
355
356#[derive(uniffi::Record)]
357pub struct RtProjectMilestone {
358    pub id: String,
359    pub workspace_id: String,
360    pub project_id: String,
361    pub project_name: String,
362    pub name: String,
363    pub description: Option<String>,
364    pub target_date: Option<String>,
365    pub status: String,
366    pub progress: f64,
367    pub sort_order: f64,
368    pub created_at: String,
369    pub updated_at: String,
370    pub archived_at: Option<String>,
371}
372
373impl From<crate::db::ProjectMilestone> for RtProjectMilestone {
374    fn from(milestone: crate::db::ProjectMilestone) -> Self {
375        Self {
376            id: milestone.id,
377            workspace_id: milestone.workspace_id,
378            project_id: milestone.project_id,
379            project_name: milestone.project_name,
380            name: milestone.name,
381            description: milestone.description,
382            target_date: milestone.target_date,
383            status: milestone.status,
384            progress: milestone.progress,
385            sort_order: milestone.sort_order,
386            created_at: milestone.created_at,
387            updated_at: milestone.updated_at,
388            archived_at: milestone.archived_at,
389        }
390    }
391}
392
393#[derive(uniffi::Record)]
394pub struct RtProjectBundle {
395    pub project: RtProject,
396    pub milestones: Vec<RtProjectMilestone>,
397    pub issues: Vec<RtIssue>,
398}
399
400impl From<crate::db::ProjectBundle> for RtProjectBundle {
401    fn from(bundle: crate::db::ProjectBundle) -> Self {
402        Self {
403            project: bundle.project.into(),
404            milestones: bundle.milestones.into_iter().map(Into::into).collect(),
405            issues: bundle.issues.into_iter().map(Into::into).collect(),
406        }
407    }
408}
409
410#[derive(uniffi::Record)]
411pub struct RtProjectMilestoneBundle {
412    pub project: RtProject,
413    pub milestone: RtProjectMilestone,
414    pub issues: Vec<RtIssue>,
415}
416
417impl From<crate::db::ProjectMilestoneBundle> for RtProjectMilestoneBundle {
418    fn from(bundle: crate::db::ProjectMilestoneBundle) -> Self {
419        Self {
420            project: bundle.project.into(),
421            milestone: bundle.milestone.into(),
422            issues: bundle.issues.into_iter().map(Into::into).collect(),
423        }
424    }
425}
426
427#[derive(uniffi::Record)]
428pub struct RtProjectSyncResult {
429    pub projects: u64,
430    pub milestones: u64,
431}
432
433#[derive(uniffi::Record)]
434pub struct RtCreateProjectInput {
435    pub name: String,
436    pub team_ids: Vec<String>,
437    pub description: Option<String>,
438    pub content: Option<String>,
439    pub icon: Option<String>,
440    pub color: Option<String>,
441    pub status_id: Option<String>,
442    pub priority: Option<i32>,
443    pub lead_id: Option<String>,
444    pub start_date: Option<String>,
445    pub target_date: Option<String>,
446    pub member_ids: Option<Vec<String>>,
447    pub label_ids: Option<Vec<String>>,
448}
449
450#[derive(uniffi::Record)]
451pub struct RtUpdateProjectInput {
452    pub name: Option<String>,
453    pub team_ids: Option<Vec<String>>,
454    pub description: Option<String>,
455    pub content: Option<String>,
456    pub icon: Option<String>,
457    pub color: Option<String>,
458    pub status_id: Option<String>,
459    pub priority: Option<i32>,
460    pub lead_id: Option<String>,
461    pub start_date: Option<String>,
462    pub target_date: Option<String>,
463    pub member_ids: Option<Vec<String>>,
464    pub label_ids: Option<Vec<String>>,
465}
466
467#[derive(uniffi::Record)]
468pub struct RtCreateProjectMilestoneInput {
469    pub project_id: String,
470    pub name: String,
471    pub description: Option<String>,
472    pub target_date: Option<String>,
473    pub sort_order: Option<f64>,
474}
475
476#[derive(uniffi::Record)]
477pub struct RtUpdateProjectMilestoneInput {
478    pub project_id: Option<String>,
479    pub name: Option<String>,
480    pub description: Option<String>,
481    pub target_date: Option<String>,
482    pub sort_order: Option<f64>,
483}
484
485#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
486pub enum RtSyncPhase {
487    FetchingIssues,
488    GeneratingEmbeddings,
489}
490
491#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
492pub struct RtSyncProgress {
493    pub phase: RtSyncPhase,
494    pub completed: u64,
495    pub total: Option<u64>,
496}
497
498impl From<crate::db::TeamSummary> for RtTeamSummary {
499    fn from(t: crate::db::TeamSummary) -> Self {
500        Self {
501            key: t.key,
502            issue_count: t.issue_count as u64,
503            embedded_count: t.embedded_count as u64,
504            last_synced_at: t.last_synced_at,
505        }
506    }
507}
508
509impl From<RtSearchMode> for search::SearchMode {
510    fn from(mode: RtSearchMode) -> Self {
511        match mode {
512            RtSearchMode::Fts => search::SearchMode::Fts,
513            RtSearchMode::Vector => search::SearchMode::Vector,
514            RtSearchMode::Hybrid => search::SearchMode::Hybrid,
515        }
516    }
517}
518
519// ── Engine ───────────────────────────────────────────────────────────
520
521#[derive(uniffi::Object)]
522pub struct RectilinearEngine {
523    db: Database,
524    gemini_api_key: Option<String>,
525    sync_progress: Mutex<Option<RtSyncProgress>>,
526    /// Lazily initialized on first async call so it's created inside
527    /// UniFFI's Tokio runtime, binding hyper's DNS resolver to a live reactor.
528    http_client: OnceCell<reqwest::Client>,
529}
530
531impl RectilinearEngine {
532    /// Get or create the HTTP client. Lazily initialized so it's created
533    /// inside the caller's Tokio runtime (UniFFI's), binding hyper's DNS
534    /// resolver to a live reactor.
535    async fn client(&self) -> &reqwest::Client {
536        self.http_client
537            .get_or_init(|| async { reqwest::Client::new() })
538            .await
539    }
540}
541
542#[uniffi::export(async_runtime = "tokio")]
543impl RectilinearEngine {
544    /// Create a new engine with an explicit database path and optional Gemini API key.
545    /// Linear API keys are resolved per-workspace from config.
546    #[uniffi::constructor]
547    pub fn new(
548        db_path: String,
549        gemini_api_key: Option<String>,
550    ) -> Result<Self, RectilinearError> {
551        let path = Path::new(&db_path);
552        if let Some(parent) = path.parent() {
553            std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
554                message: format!("Failed to create database directory: {e}"),
555            })?;
556        }
557
558        let db = Database::open(path)?;
559
560        Ok(Self {
561            db,
562            gemini_api_key,
563            sync_progress: Mutex::new(None),
564            http_client: OnceCell::new(),
565        })
566    }
567
568    /// Resolve the Linear API key for a given workspace from config.
569    pub fn linear_api_key_for_workspace(
570        &self,
571        workspace_id: &str,
572    ) -> Result<String, RectilinearError> {
573        let config = Config::load().map_err(|e| RectilinearError::Config {
574            message: e.to_string(),
575        })?;
576        config
577            .workspace_api_key(workspace_id)
578            .map_err(|e| RectilinearError::Config {
579                message: e.to_string(),
580            })
581    }
582
583    /// List all configured workspace names.
584    pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
585        let config = Config::load().map_err(|e| RectilinearError::Config {
586            message: e.to_string(),
587        })?;
588        Ok(config.workspace_names())
589    }
590
591    /// Get the active workspace name.
592    pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
593        let config = Config::load().map_err(|e| RectilinearError::Config {
594            message: e.to_string(),
595        })?;
596        config
597            .resolve_active_workspace()
598            .map_err(|e| RectilinearError::Config {
599                message: e.to_string(),
600            })
601    }
602
603    // ── Sync methods (database reads, fast) ──────────────────────
604
605    /// Look up an issue by UUID or identifier (e.g. "CUT-123").
606    pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
607        Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
608    }
609
610    /// List cached projects. Call sync_projects first when fresh metadata is required.
611    pub fn list_projects(
612        &self,
613        workspace_id: String,
614        include_archived: bool,
615    ) -> Result<Vec<RtProject>, RectilinearError> {
616        Ok(self
617            .db
618            .list_projects(&workspace_id, include_archived)?
619            .into_iter()
620            .map(Into::into)
621            .collect())
622    }
623
624    /// Get cached project metadata by UUID, slug, or name.
625    pub fn get_project(
626        &self,
627        id_or_name: String,
628        workspace_id: String,
629    ) -> Result<Option<RtProject>, RectilinearError> {
630        Ok(self
631            .db
632            .get_project(&workspace_id, &id_or_name)?
633            .map(Into::into))
634    }
635
636    /// List cached milestones for a project UUID.
637    pub fn list_project_milestones(
638        &self,
639        project_id: String,
640    ) -> Result<Vec<RtProjectMilestone>, RectilinearError> {
641        Ok(self
642            .db
643            .list_project_milestones(&project_id)?
644            .into_iter()
645            .map(Into::into)
646            .collect())
647    }
648
649    /// Get a cached project bundle, including milestones and linked issues.
650    pub fn get_project_bundle(
651        &self,
652        id_or_name: String,
653        workspace_id: String,
654    ) -> Result<Option<RtProjectBundle>, RectilinearError> {
655        Ok(self
656            .db
657            .get_project_bundle(&workspace_id, &id_or_name)?
658            .map(Into::into))
659    }
660
661    /// Get a cached milestone bundle, including its project and linked issues.
662    pub fn get_project_milestone_bundle(
663        &self,
664        id_or_name: String,
665        project_id: Option<String>,
666        workspace_id: String,
667    ) -> Result<Option<RtProjectMilestoneBundle>, RectilinearError> {
668        Ok(self
669            .db
670            .get_project_milestone_bundle(
671                &workspace_id,
672                &id_or_name,
673                project_id.as_deref(),
674            )?
675            .map(Into::into))
676    }
677
678    /// Get unprioritized issues for triage.
679    pub fn get_triage_queue(
680        &self,
681        team: Option<String>,
682        include_completed: bool,
683        workspace_id: String,
684    ) -> Result<Vec<RtIssue>, RectilinearError> {
685        let issues =
686            self.db
687                .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
688        Ok(issues.into_iter().map(RtIssue::from).collect())
689    }
690
691    /// Full-text search (FTS5, BM25 ranking). Synchronous — hits local SQLite only.
692    pub fn search_fts(
693        &self,
694        query: String,
695        limit: u32,
696        workspace_id: String,
697    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
698        let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
699        Ok(results
700            .into_iter()
701            .map(|fts| RtSearchResult {
702                issue_id: fts.issue_id,
703                identifier: fts.identifier,
704                title: fts.title,
705                state_name: fts.state_name,
706                priority: fts.priority,
707                score: fts.bm25_score,
708                similarity: None,
709            })
710            .collect())
711    }
712
713    /// Count issues in the local database.
714    pub fn count_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
715        Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
716    }
717
718    /// Count issues that have at least one embedding chunk.
719    pub fn count_embedded_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
720        Ok(self.db.count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
721    }
722
723    /// Return the current sync progress, if a sync or embedding pass is active.
724    pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
725        self.sync_progress.lock().unwrap().clone()
726    }
727
728    /// Get field completeness counts in a single query.
729    pub fn get_field_completeness(
730        &self,
731        team: Option<String>,
732        workspace_id: String,
733    ) -> Result<RtFieldCompleteness, RectilinearError> {
734        let (total, desc, pri, labels, proj) =
735            self.db.get_field_completeness(team.as_deref(), &workspace_id)?;
736        Ok(RtFieldCompleteness {
737            total: total as u64,
738            with_description: desc as u64,
739            with_priority: pri as u64,
740            with_labels: labels as u64,
741            with_project: proj as u64,
742        })
743    }
744
745    /// List all issues with lightweight summary data. Supports pagination and filtering.
746    pub fn list_all_issues(
747        &self,
748        team: Option<String>,
749        filter: Option<String>,
750        limit: u32,
751        offset: u32,
752        workspace_id: String,
753    ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
754        let issues = self.db.list_all_issues(
755            team.as_deref(),
756            filter.as_deref(),
757            limit as usize,
758            offset as usize,
759            &workspace_id,
760        )?;
761        Ok(issues.into_iter().map(RtIssueSummary::from).collect())
762    }
763
764    /// List teams with synced issues and their embedding coverage. Local-only, no network.
765    pub fn list_synced_teams(&self, workspace_id: String) -> Result<Vec<RtTeamSummary>, RectilinearError> {
766        Ok(self
767            .db
768            .list_synced_teams(&workspace_id)?
769            .into_iter()
770            .map(RtTeamSummary::from)
771            .collect())
772    }
773
774    /// Get enriched relations for an issue.
775    pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
776        Ok(self
777            .db
778            .get_relations_enriched(&issue_id)?
779            .into_iter()
780            .map(RtRelation::from)
781            .collect())
782    }
783
784    /// Get issues filtered by team and state types, enriched with blocker info.
785    pub fn get_active_issues(
786        &self,
787        team: String,
788        state_types: Vec<String>,
789        workspace_id: String,
790    ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
791        let issues = self
792            .db
793            .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
794        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
795        let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
796
797        // Group blockers by issue ID
798        let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
799            std::collections::HashMap::new();
800        for b in blockers {
801            let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
802            blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
803                identifier: b.identifier,
804                title: b.title,
805                state_name: b.state_name,
806                is_terminal,
807            });
808        }
809
810        Ok(issues
811            .into_iter()
812            .map(|issue| {
813                let labels: Vec<String> =
814                    serde_json::from_str(&issue.labels_json).unwrap_or_default();
815                let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
816                RtIssueEnriched {
817                    id: issue.id,
818                    identifier: issue.identifier,
819                    team_key: issue.team_key,
820                    title: issue.title,
821                    description: issue.description,
822                    state_name: issue.state_name,
823                    state_type: issue.state_type,
824                    priority: issue.priority,
825                    assignee_name: issue.assignee_name,
826                    project_name: issue.project_name,
827                    project_id: issue.project_id,
828                    project_milestone_id: issue.project_milestone_id,
829                    project_milestone_name: issue.project_milestone_name,
830                    labels,
831                    created_at: issue.created_at,
832                    updated_at: issue.updated_at,
833                    url: issue.url,
834                    branch_name: issue.branch_name,
835                    blocked_by,
836                }
837            })
838            .collect())
839    }
840
841    // ── Async methods (network I/O) ─────────────────────────────
842
843    /// List all teams from Linear.
844    pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
845        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
846        let client =
847            LinearClient::with_http_client(self.client().await.clone(), &api_key);
848        let teams = client
849            .list_teams()
850            .await
851            .map_err(|e| RectilinearError::Api {
852                message: e.to_string(),
853            })?;
854        Ok(teams
855            .into_iter()
856            .map(|t| RtTeam {
857                id: t.id,
858                key: t.key,
859                name: t.name,
860            })
861            .collect())
862    }
863
864    /// Validate the configured Gemini API key without generating embeddings.
865    pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
866        let api_key = self
867            .gemini_api_key
868            .as_deref()
869            .ok_or_else(|| RectilinearError::Config {
870                message: "Gemini API key not configured".into(),
871            })?;
872
873        crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
874            .map_err(|e| RectilinearError::Config {
875                message: e.to_string(),
876            })?
877            .test_api_key()
878            .await
879            .map_err(|e| RectilinearError::Api {
880                message: e.to_string(),
881            })
882    }
883
884    /// Sync issues from Linear for a team. Returns the number of issues synced.
885    pub async fn sync_projects(
886        &self,
887        workspace_id: String,
888    ) -> Result<RtProjectSyncResult, RectilinearError> {
889        let client = self.linear_client(&workspace_id).await?;
890        let (projects, milestones) = client
891            .sync_projects(&self.db, &workspace_id)
892            .await
893            .map_err(api_error)?;
894        Ok(RtProjectSyncResult {
895            projects: projects as u64,
896            milestones: milestones as u64,
897        })
898    }
899
900    /// Import and return a complete project hierarchy with every linked issue.
901    pub async fn import_project(
902        &self,
903        id_or_name: String,
904        workspace_id: String,
905    ) -> Result<RtProjectBundle, RectilinearError> {
906        let client = self.linear_client(&workspace_id).await?;
907        client
908            .import_project(&self.db, &workspace_id, &id_or_name)
909            .await
910            .map(Into::into)
911            .map_err(api_error)
912    }
913
914    /// Import and return a complete milestone hierarchy with every linked issue.
915    pub async fn import_project_milestone(
916        &self,
917        id_or_name: String,
918        project_id: Option<String>,
919        workspace_id: String,
920    ) -> Result<RtProjectMilestoneBundle, RectilinearError> {
921        let client = self.linear_client(&workspace_id).await?;
922        client
923            .import_project_milestone(
924                &self.db,
925                &workspace_id,
926                project_id.as_deref(),
927                &id_or_name,
928            )
929            .await
930            .map(Into::into)
931            .map_err(api_error)
932    }
933
934    /// Create a project. Relationship fields use Linear model UUIDs.
935    pub async fn create_project(
936        &self,
937        input: RtCreateProjectInput,
938        workspace_id: String,
939    ) -> Result<RtProject, RectilinearError> {
940        let client = self.linear_client(&workspace_id).await?;
941        let input = crate::linear::CreateProjectInput {
942            name: input.name,
943            team_ids: input.team_ids,
944            description: input.description,
945            content: input.content,
946            icon: input.icon,
947            color: input.color,
948            status_id: input.status_id,
949            priority: input.priority,
950            lead_id: input.lead_id,
951            start_date: input.start_date,
952            target_date: input.target_date,
953            member_ids: input.member_ids,
954            label_ids: input.label_ids,
955        };
956        let id = client.create_project(&input).await.map_err(api_error)?;
957        let project = client
958            .fetch_project(&id, &workspace_id)
959            .await
960            .map_err(api_error)?;
961        self.db.upsert_project(&project)?;
962        Ok(project.into())
963    }
964
965    /// Update a project. Empty strings clear nullable metadata fields.
966    pub async fn update_project(
967        &self,
968        id_or_name: String,
969        input: RtUpdateProjectInput,
970        workspace_id: String,
971    ) -> Result<RtProject, RectilinearError> {
972        let client = self.linear_client(&workspace_id).await?;
973        let id = client
974            .find_project_by_name(&id_or_name)
975            .await
976            .map_err(api_error)?;
977        let input = crate::linear::UpdateProjectInput {
978            name: input.name,
979            team_ids: input.team_ids,
980            description: input.description,
981            content: input.content,
982            icon: input.icon,
983            color: input.color,
984            status_id: input.status_id,
985            priority: input.priority,
986            lead_id: input.lead_id,
987            start_date: input.start_date,
988            target_date: input.target_date,
989            member_ids: input.member_ids,
990            label_ids: input.label_ids,
991        };
992        client.update_project(&id, &input).await.map_err(api_error)?;
993        let project = client
994            .fetch_project(&id, &workspace_id)
995            .await
996            .map_err(api_error)?;
997        self.db.upsert_project(&project)?;
998        Ok(project.into())
999    }
1000
1001    /// Delete (archive) a project in Linear and remove its cached hierarchy.
1002    pub async fn delete_project(
1003        &self,
1004        id_or_name: String,
1005        workspace_id: String,
1006    ) -> Result<(), RectilinearError> {
1007        let client = self.linear_client(&workspace_id).await?;
1008        let id = client
1009            .find_project_by_name(&id_or_name)
1010            .await
1011            .map_err(api_error)?;
1012        client.delete_project(&id).await.map_err(api_error)?;
1013        self.db.delete_project_local(&id)?;
1014        Ok(())
1015    }
1016
1017    /// Create a project milestone. The project_id must be a Linear project UUID.
1018    pub async fn create_project_milestone(
1019        &self,
1020        input: RtCreateProjectMilestoneInput,
1021        workspace_id: String,
1022    ) -> Result<RtProjectMilestone, RectilinearError> {
1023        let client = self.linear_client(&workspace_id).await?;
1024        let input = crate::linear::CreateProjectMilestoneInput {
1025            project_id: input.project_id,
1026            name: input.name,
1027            description: input.description,
1028            target_date: input.target_date,
1029            sort_order: input.sort_order,
1030        };
1031        let id = client
1032            .create_project_milestone(&input)
1033            .await
1034            .map_err(api_error)?;
1035        self.cache_project_milestone(&client, &id, &workspace_id)
1036            .await
1037    }
1038
1039    /// Update or move a project milestone.
1040    pub async fn update_project_milestone(
1041        &self,
1042        id_or_name: String,
1043        owning_project_id: Option<String>,
1044        input: RtUpdateProjectMilestoneInput,
1045        workspace_id: String,
1046    ) -> Result<RtProjectMilestone, RectilinearError> {
1047        let client = self.linear_client(&workspace_id).await?;
1048        let id = client
1049            .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1050            .await
1051            .map_err(api_error)?;
1052        let input = crate::linear::UpdateProjectMilestoneInput {
1053            project_id: input.project_id,
1054            name: input.name,
1055            description: input.description,
1056            target_date: input.target_date,
1057            sort_order: input.sort_order,
1058        };
1059        client
1060            .update_project_milestone(&id, &input)
1061            .await
1062            .map_err(api_error)?;
1063        self.cache_project_milestone(&client, &id, &workspace_id)
1064            .await
1065    }
1066
1067    /// Delete a project milestone in Linear and remove it from the local hierarchy.
1068    pub async fn delete_project_milestone(
1069        &self,
1070        id_or_name: String,
1071        owning_project_id: Option<String>,
1072        workspace_id: String,
1073    ) -> Result<(), RectilinearError> {
1074        let client = self.linear_client(&workspace_id).await?;
1075        let id = client
1076            .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1077            .await
1078            .map_err(api_error)?;
1079        client
1080            .delete_project_milestone(&id)
1081            .await
1082            .map_err(api_error)?;
1083        self.db.delete_project_milestone_local(&id)?;
1084        Ok(())
1085    }
1086
1087    /// Replace an issue's project and milestone relationship. Passing nil clears that relationship.
1088    pub async fn set_issue_project_context(
1089        &self,
1090        issue_id: String,
1091        project_id: Option<String>,
1092        project_milestone_id: Option<String>,
1093        workspace_id: String,
1094    ) -> Result<RtIssue, RectilinearError> {
1095        let client = self.linear_client(&workspace_id).await?;
1096        let project_id = match (project_id, project_milestone_id.as_deref()) {
1097            (Some(project_id), _) => Some(project_id),
1098            (None, Some(milestone_id)) => Some(
1099                client
1100                    .fetch_project_milestone(milestone_id, &workspace_id)
1101                    .await
1102                    .map_err(api_error)?
1103                    .project_id,
1104            ),
1105            (None, None) => None,
1106        };
1107        let project_value = project_id.unwrap_or_default();
1108        let milestone_value = project_milestone_id.unwrap_or_default();
1109        client
1110            .update_issue(
1111                &issue_id,
1112                crate::linear::UpdateIssueInput {
1113                    project_id: Some(&project_value),
1114                    project_milestone_id: Some(&milestone_value),
1115                    ..Default::default()
1116                },
1117            )
1118            .await
1119            .map_err(api_error)?;
1120        let (mut issue, relations, label_ids) = client
1121            .fetch_single_issue(&issue_id)
1122            .await
1123            .map_err(api_error)?;
1124        issue.workspace_id = workspace_id;
1125        self.db.upsert_issue(&issue)?;
1126        self.db.upsert_relations(&issue.id, &relations)?;
1127        self.db.replace_issue_labels(&issue.id, &label_ids)?;
1128        Ok(issue.into())
1129    }
1130
1131    /// Sync issues from Linear for a team. Returns the number of issues synced.
1132    pub async fn sync_team(&self, team_key: String, full: bool, workspace_id: String) -> Result<u64, RectilinearError> {
1133        self.set_sync_progress(Some(RtSyncProgress {
1134            phase: RtSyncPhase::FetchingIssues,
1135            completed: 0,
1136            total: None,
1137        }));
1138
1139        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1140        let client =
1141            LinearClient::with_http_client(self.client().await.clone(), &api_key);
1142        let progress_state = &self.sync_progress;
1143        let progress = move |count: usize| {
1144            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1145                phase: RtSyncPhase::FetchingIssues,
1146                completed: count as u64,
1147                total: None,
1148            });
1149        };
1150        let result = client
1151            .sync_team(&self.db, &team_key, &workspace_id, full, false, Some(&progress))
1152            .await
1153            .map_err(|e| RectilinearError::Api {
1154                message: e.to_string(),
1155            });
1156        self.set_sync_progress(None);
1157        result.map(|count| count as u64)
1158    }
1159
1160    /// Hybrid search (FTS + vector via RRF). Requires embedder for vector component.
1161    pub async fn search_hybrid(
1162        &self,
1163        query: String,
1164        team: Option<String>,
1165        limit: u32,
1166        workspace_id: String,
1167    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1168        let config = Config::load().unwrap_or_default();
1169        let embedder = self.make_embedder(&config).await?;
1170
1171        let results = search::search(
1172            &self.db,
1173            search::SearchParams {
1174                query: &query,
1175                mode: search::SearchMode::Hybrid,
1176                team_key: team.as_deref(),
1177                state_filter: None,
1178                label_ids: None,
1179                limit: limit as usize,
1180                embedder: embedder.as_ref(),
1181                rrf_k: config.search.rrf_k,
1182                workspace_id: &workspace_id,
1183            },
1184        )
1185        .await?;
1186
1187        Ok(results.into_iter().map(RtSearchResult::from).collect())
1188    }
1189
1190    /// Find potential duplicate issues by semantic similarity.
1191    pub async fn find_duplicates(
1192        &self,
1193        text: String,
1194        team: Option<String>,
1195        threshold: f32,
1196        workspace_id: String,
1197    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1198        let config = Config::load().unwrap_or_default();
1199        let embedder =
1200            self.make_embedder(&config)
1201                .await?
1202                .ok_or_else(|| RectilinearError::Config {
1203                    message:
1204                        "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
1205                            .into(),
1206                })?;
1207
1208        let results = search::find_duplicates(
1209            &self.db,
1210            &text,
1211            team.as_deref(),
1212            threshold,
1213            10,
1214            &embedder,
1215            config.search.rrf_k,
1216            &workspace_id,
1217        )
1218        .await?;
1219
1220        Ok(results.into_iter().map(RtSearchResult::from).collect())
1221    }
1222
1223    /// Update an issue in Linear (title, description, priority, state, labels).
1224    pub async fn save_issue(
1225        &self,
1226        issue_id: String,
1227        title: Option<String>,
1228        description: Option<String>,
1229        priority: Option<i32>,
1230        state: Option<String>,
1231        labels: Option<Vec<String>>,
1232        workspace_id: String,
1233    ) -> Result<(), RectilinearError> {
1234        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1235        let client =
1236            LinearClient::with_http_client(self.client().await.clone(), &api_key);
1237
1238        let state_id = if let Some(ref state_name) = state {
1239            // Need to resolve state name → ID. Get team from issue first.
1240            if let Some(issue) = self.db.get_issue(&issue_id)? {
1241                Some(
1242                    client
1243                        .get_state_id(&issue.team_key, state_name)
1244                        .await
1245                        .map_err(|e| RectilinearError::Api {
1246                            message: e.to_string(),
1247                        })?,
1248                )
1249            } else {
1250                None
1251            }
1252        } else {
1253            None
1254        };
1255
1256        let label_ids =
1257            if let Some(ref label_names) = labels {
1258                Some(client.get_label_ids(label_names).await.map_err(|e| {
1259                    RectilinearError::Api {
1260                        message: e.to_string(),
1261                    }
1262                })?)
1263            } else {
1264                None
1265            };
1266
1267        client
1268            .update_issue(
1269                &issue_id,
1270                crate::linear::UpdateIssueInput {
1271                    title: title.as_deref(),
1272                    description: description.as_deref(),
1273                    priority,
1274                    state_id: state_id.as_deref(),
1275                    label_ids: label_ids.as_deref(),
1276                    ..Default::default()
1277                },
1278            )
1279            .await
1280            .map_err(|e| RectilinearError::Api {
1281                message: e.to_string(),
1282            })?;
1283
1284        // Re-sync the updated issue back to local DB
1285        if let Ok((issue, relations, label_ids)) = client.fetch_single_issue(&issue_id).await {
1286            let _ = self.db.upsert_issue(&issue);
1287            let _ = self.db.upsert_relations(&issue.id, &relations);
1288            let _ = self.db.replace_issue_labels(&issue.id, &label_ids);
1289        }
1290
1291        Ok(())
1292    }
1293
1294    /// Create a new issue in Linear and return its (id, identifier).
1295    pub async fn create_issue(
1296        &self,
1297        input: RtCreateIssueInput,
1298        workspace_id: String,
1299    ) -> Result<RtCreateIssueResult, RectilinearError> {
1300        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1301        let client =
1302            LinearClient::with_http_client(self.client().await.clone(), &api_key);
1303
1304        let team_id = client
1305            .get_team_id(&input.team_key)
1306            .await
1307            .map_err(|e| RectilinearError::Api {
1308                message: e.to_string(),
1309            })?;
1310        let project_id = match (
1311            input.project_id.as_deref(),
1312            input.project_milestone_id.as_deref(),
1313        ) {
1314            (Some(project_id), _) => Some(project_id.to_string()),
1315            (None, Some(milestone_id)) => Some(
1316                client
1317                    .fetch_project_milestone(milestone_id, &workspace_id)
1318                    .await
1319                    .map_err(api_error)?
1320                    .project_id,
1321            ),
1322            (None, None) => None,
1323        };
1324
1325        let (id, identifier) = client
1326            .create_issue(crate::linear::CreateIssueInput {
1327                team_id: &team_id,
1328                title: &input.title,
1329                description: input.description.as_deref(),
1330                priority: input.priority,
1331                label_ids: &input.label_ids,
1332                assignee_id: None,
1333                parent_id: input.parent_id.as_deref(),
1334                project_id: project_id.as_deref(),
1335                project_milestone_id: input.project_milestone_id.as_deref(),
1336            })
1337            .await
1338            .map_err(|e| RectilinearError::Api {
1339                message: e.to_string(),
1340            })?;
1341
1342        Ok(RtCreateIssueResult { id, identifier })
1343    }
1344
1345    /// Add a comment to a Linear issue.
1346    pub async fn add_comment(
1347        &self,
1348        issue_id: String,
1349        body: String,
1350        workspace_id: String,
1351    ) -> Result<(), RectilinearError> {
1352        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1353        let client =
1354            LinearClient::with_http_client(self.client().await.clone(), &api_key);
1355        client
1356            .add_comment(&issue_id, &body)
1357            .await
1358            .map_err(|e| RectilinearError::Api {
1359                message: e.to_string(),
1360            })
1361    }
1362
1363    /// Fetch a single issue live from Linear and upsert into local DB.
1364    /// Accepts either a UUID or identifier (e.g. "CUT-123").
1365    pub async fn refresh_issue(
1366        &self,
1367        id_or_identifier: String,
1368        workspace_id: String,
1369    ) -> Result<Option<RtIssue>, RectilinearError> {
1370        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1371        let client =
1372            LinearClient::with_http_client(self.client().await.clone(), &api_key);
1373
1374        let result = if id_or_identifier.contains('-')
1375            && id_or_identifier
1376                .chars()
1377                .last()
1378                .is_some_and(|c| c.is_ascii_digit())
1379        {
1380            client
1381                .fetch_issue_by_identifier(&id_or_identifier)
1382                .await
1383                .map_err(|e| RectilinearError::Api {
1384                    message: e.to_string(),
1385                })?
1386        } else {
1387            Some(
1388                client
1389                    .fetch_single_issue(&id_or_identifier)
1390                    .await
1391                    .map_err(|e| RectilinearError::Api {
1392                        message: e.to_string(),
1393                    })?,
1394            )
1395        };
1396
1397        if let Some((issue, relations, label_ids)) = result {
1398            self.db.upsert_issue(&issue)?;
1399            self.db.upsert_relations(&issue.id, &relations)?;
1400            self.db.replace_issue_labels(&issue.id, &label_ids)?;
1401            Ok(Some(RtIssue::from(issue)))
1402        } else {
1403            Ok(None)
1404        }
1405    }
1406
1407    /// Generate embeddings for issues that don't have them yet.
1408    /// Returns the number of issues embedded.
1409    pub async fn embed_issues(
1410        &self,
1411        team: Option<String>,
1412        limit: u32,
1413        workspace_id: String,
1414    ) -> Result<u64, RectilinearError> {
1415        let config = Config::load().unwrap_or_default();
1416        let embedder =
1417            self.make_embedder(&config)
1418                .await?
1419                .ok_or_else(|| {
1420                    RectilinearError::Config {
1421                message:
1422                    "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
1423                        .into(),
1424            }
1425                })?;
1426
1427        let model_name = embedder.backend_name().to_string();
1428        let issues = self
1429            .db
1430            .get_issues_needing_embedding(team.as_deref(), false, &workspace_id)?;
1431
1432        let to_process = if limit > 0 {
1433            &issues[..std::cmp::min(issues.len(), limit as usize)]
1434        } else {
1435            &issues
1436        };
1437        let total = to_process.len() as u64;
1438
1439        self.set_sync_progress(Some(RtSyncProgress {
1440            phase: RtSyncPhase::GeneratingEmbeddings,
1441            completed: 0,
1442            total: Some(total),
1443        }));
1444
1445        // Collect chunks from multiple issues into batches to reduce API round-trips.
1446        // Each Gemini batchEmbedContents call handles up to 100 texts, so we fill
1447        // batches across issue boundaries rather than making one call per issue.
1448        const BATCH_SIZE: usize = 100;
1449
1450        // Pre-chunk all issues, skipping those already embedded with the current model.
1451        struct IssueChunks {
1452            issue_id: String,
1453            chunks: Vec<String>,
1454        }
1455        let mut pending: Vec<IssueChunks> = Vec::new();
1456        for issue in to_process {
1457            if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
1458                if existing_model == model_name {
1459                    continue;
1460                }
1461            }
1462            let chunks = crate::embedding::chunk_text(
1463                &issue.title,
1464                issue.description.as_deref().unwrap_or(""),
1465                512,
1466                64,
1467            );
1468            pending.push(IssueChunks {
1469                issue_id: issue.id.clone(),
1470                chunks,
1471            });
1472        }
1473
1474        let result: Result<u64, RectilinearError> = async {
1475            // Flatten all chunks into a single list with back-references to their issue.
1476            // Each entry: (index into `pending`, chunk_index_within_issue, chunk_text)
1477            let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
1478            for (issue_idx, ic) in pending.iter().enumerate() {
1479                for (chunk_idx, text) in ic.chunks.iter().enumerate() {
1480                    flat_chunks.push((issue_idx, chunk_idx, text.clone()));
1481                }
1482            }
1483
1484            // Embed in batches of BATCH_SIZE across issue boundaries.
1485            let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
1486            for batch in flat_chunks.chunks(BATCH_SIZE) {
1487                let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
1488                let batch_embeddings =
1489                    embedder
1490                        .embed_batch(&texts)
1491                        .await
1492                        .map_err(|e| RectilinearError::Api {
1493                            message: e.to_string(),
1494                        })?;
1495                embeddings_flat.extend(batch_embeddings);
1496            }
1497
1498            // Re-group embeddings back to their issues and persist.
1499            let mut emb_offset = 0usize;
1500            let mut count = 0u64;
1501            for ic in &pending {
1502                let n = ic.chunks.len();
1503                let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
1504
1505                let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
1506                    .chunks
1507                    .iter()
1508                    .zip(issue_embeddings.iter())
1509                    .enumerate()
1510                    .map(|(idx, (text, emb))| {
1511                        (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
1512                    })
1513                    .collect();
1514
1515                self.db
1516                    .upsert_chunks_with_model(&ic.issue_id, &chunk_data, &model_name)?;
1517                emb_offset += n;
1518                count += 1;
1519                self.set_sync_progress(Some(RtSyncProgress {
1520                    phase: RtSyncPhase::GeneratingEmbeddings,
1521                    completed: count,
1522                    total: Some(total),
1523                }));
1524            }
1525
1526            Ok(count)
1527        }
1528        .await;
1529
1530        self.set_sync_progress(None);
1531        result
1532    }
1533}
1534
1535// ── Private helpers ──────────────────────────────────────────────────
1536
1537impl RectilinearEngine {
1538    async fn linear_client(
1539        &self,
1540        workspace_id: &str,
1541    ) -> Result<LinearClient, RectilinearError> {
1542        let api_key = self.linear_api_key_for_workspace(workspace_id)?;
1543        Ok(LinearClient::with_http_client(
1544            self.client().await.clone(),
1545            &api_key,
1546        ))
1547    }
1548
1549    async fn cache_project_milestone(
1550        &self,
1551        client: &LinearClient,
1552        milestone_id: &str,
1553        workspace_id: &str,
1554    ) -> Result<RtProjectMilestone, RectilinearError> {
1555        let milestone = client
1556            .fetch_project_milestone(milestone_id, workspace_id)
1557            .await
1558            .map_err(api_error)?;
1559        let project = client
1560            .fetch_project(&milestone.project_id, workspace_id)
1561            .await
1562            .map_err(api_error)?;
1563        self.db.upsert_project(&project)?;
1564        self.db.upsert_project_milestone(&milestone)?;
1565        Ok(milestone.into())
1566    }
1567
1568    fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
1569        *self.sync_progress.lock().unwrap() = progress;
1570    }
1571
1572    async fn make_embedder(
1573        &self,
1574        config: &Config,
1575    ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
1576        let key = self
1577            .gemini_api_key
1578            .as_deref()
1579            .or(config.embedding.gemini_api_key.as_deref());
1580
1581        if let Some(api_key) = key {
1582            Ok(Some(
1583                crate::embedding::Embedder::new_api_with_http_client(
1584                    self.client().await.clone(),
1585                    api_key,
1586                )
1587                .map_err(|e| RectilinearError::Config {
1588                    message: e.to_string(),
1589                })?,
1590            ))
1591        } else {
1592            #[cfg(feature = "local-embeddings")]
1593            {
1594                let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
1595                    message: e.to_string(),
1596                })?;
1597                Ok(Some(
1598                    crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
1599                        RectilinearError::Config {
1600                            message: e.to_string(),
1601                        }
1602                    })?,
1603                ))
1604            }
1605            #[cfg(not(feature = "local-embeddings"))]
1606            {
1607                Ok(None)
1608            }
1609        }
1610    }
1611}
1612
1613fn api_error(error: anyhow::Error) -> RectilinearError {
1614    RectilinearError::Api {
1615        message: error.to_string(),
1616    }
1617}