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