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    /// Nil until Linear reports that the issue is archived.
62    pub archived_at: Option<String>,
63}
64
65impl From<crate::db::Issue> for RtIssue {
66    fn from(issue: crate::db::Issue) -> Self {
67        let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
68        Self {
69            id: issue.id,
70            identifier: issue.identifier,
71            team_key: issue.team_key,
72            title: issue.title,
73            description: issue.description,
74            state_name: issue.state_name,
75            state_type: issue.state_type,
76            priority: issue.priority,
77            assignee_name: issue.assignee_name,
78            project_name: issue.project_name,
79            project_id: issue.project_id,
80            project_milestone_id: issue.project_milestone_id,
81            project_milestone_name: issue.project_milestone_name,
82            cycle_id: issue.cycle_id,
83            cycle_name: issue.cycle_name,
84            labels,
85            created_at: issue.created_at,
86            updated_at: issue.updated_at,
87            url: issue.url,
88            branch_name: issue.branch_name,
89            archived_at: issue.archived_at,
90        }
91    }
92}
93
94#[derive(uniffi::Record)]
95pub struct RtSearchResult {
96    pub issue_id: String,
97    pub identifier: String,
98    pub title: String,
99    pub state_name: String,
100    pub priority: i32,
101    pub score: f64,
102    pub similarity: Option<f32>,
103}
104
105impl From<search::SearchResult> for RtSearchResult {
106    fn from(sr: search::SearchResult) -> Self {
107        Self {
108            issue_id: sr.issue_id,
109            identifier: sr.identifier,
110            title: sr.title,
111            state_name: sr.state_name,
112            priority: sr.priority,
113            score: sr.score,
114            similarity: sr.similarity,
115        }
116    }
117}
118
119#[derive(uniffi::Record)]
120pub struct RtRelation {
121    pub relation_type: String,
122    pub issue_identifier: String,
123    pub issue_title: String,
124    pub issue_state: String,
125    pub issue_url: String,
126}
127
128impl From<crate::db::EnrichedRelation> for RtRelation {
129    fn from(rel: crate::db::EnrichedRelation) -> Self {
130        Self {
131            relation_type: rel.relation_type,
132            issue_identifier: rel.issue_identifier,
133            issue_title: rel.issue_title,
134            issue_state: rel.issue_state,
135            issue_url: rel.issue_url,
136        }
137    }
138}
139
140#[derive(uniffi::Record)]
141pub struct RtBlocker {
142    pub identifier: String,
143    pub title: String,
144    pub state_name: String,
145    pub is_terminal: bool,
146}
147
148#[derive(uniffi::Record)]
149pub struct RtIssueEnriched {
150    pub id: String,
151    pub identifier: String,
152    pub team_key: String,
153    pub title: String,
154    pub description: Option<String>,
155    pub state_name: String,
156    pub state_type: String,
157    pub priority: i32,
158    pub assignee_name: Option<String>,
159    pub project_name: Option<String>,
160    pub project_id: Option<String>,
161    pub project_milestone_id: Option<String>,
162    pub project_milestone_name: Option<String>,
163    pub cycle_id: Option<String>,
164    pub cycle_name: Option<String>,
165    pub labels: Vec<String>,
166    pub created_at: String,
167    pub updated_at: String,
168    pub url: String,
169    pub branch_name: Option<String>,
170    pub blocked_by: Vec<RtBlocker>,
171}
172
173#[derive(uniffi::Record)]
174pub struct RtTeam {
175    pub id: String,
176    pub key: String,
177    pub name: String,
178}
179
180#[derive(uniffi::Enum)]
181pub enum RtSearchMode {
182    Fts,
183    Vector,
184    Hybrid,
185}
186
187#[derive(uniffi::Record)]
188pub struct RtFieldCompleteness {
189    pub total: u64,
190    pub with_description: u64,
191    pub with_priority: u64,
192    pub with_labels: u64,
193    pub with_project: u64,
194}
195
196#[derive(uniffi::Record)]
197pub struct RtIssueSummary {
198    pub id: String,
199    pub identifier: String,
200    pub team_key: String,
201    pub title: String,
202    pub state_name: String,
203    pub state_type: String,
204    pub priority: i32,
205    pub project_name: Option<String>,
206    pub labels: Vec<String>,
207    pub updated_at: String,
208    pub url: String,
209    pub has_description: bool,
210    pub has_embedding: bool,
211}
212
213impl From<crate::db::IssueSummary> for RtIssueSummary {
214    fn from(s: crate::db::IssueSummary) -> Self {
215        Self {
216            id: s.id,
217            identifier: s.identifier,
218            team_key: s.team_key,
219            title: s.title,
220            state_name: s.state_name,
221            state_type: s.state_type,
222            priority: s.priority,
223            project_name: s.project_name,
224            labels: s.labels,
225            updated_at: s.updated_at,
226            url: s.url,
227            has_description: s.has_description,
228            has_embedding: s.has_embedding,
229        }
230    }
231}
232
233#[derive(uniffi::Record)]
234pub struct RtTeamSummary {
235    pub key: String,
236    pub issue_count: u64,
237    pub embedded_count: u64,
238    pub last_synced_at: Option<String>,
239}
240
241#[derive(uniffi::Record)]
242pub struct RtCreateIssueResult {
243    pub id: String,
244    pub identifier: String,
245}
246
247#[derive(uniffi::Record)]
248pub struct RtCreateIssueInput {
249    pub team_key: String,
250    pub title: String,
251    pub description: Option<String>,
252    pub priority: Option<i32>,
253    pub label_ids: Vec<String>,
254    pub parent_id: Option<String>,
255    pub project_id: Option<String>,
256    pub project_milestone_id: Option<String>,
257}
258
259#[derive(uniffi::Record)]
260pub struct RtProjectTeam {
261    pub id: String,
262    pub key: String,
263    pub name: String,
264}
265
266#[derive(uniffi::Record)]
267pub struct RtProjectMember {
268    pub id: String,
269    pub name: String,
270}
271
272#[derive(uniffi::Record)]
273pub struct RtProjectLabel {
274    pub id: String,
275    pub name: String,
276    pub color: String,
277    pub description: Option<String>,
278}
279
280#[derive(uniffi::Record)]
281pub struct RtProject {
282    pub id: String,
283    pub workspace_id: String,
284    pub slug_id: String,
285    pub name: String,
286    pub description: String,
287    pub content: Option<String>,
288    pub icon: Option<String>,
289    pub color: String,
290    pub status_id: String,
291    pub status_name: String,
292    pub status_type: String,
293    pub status_color: String,
294    pub priority: i32,
295    pub start_date: Option<String>,
296    pub target_date: Option<String>,
297    pub lead_id: Option<String>,
298    pub lead_name: Option<String>,
299    pub created_at: String,
300    pub updated_at: String,
301    pub archived_at: Option<String>,
302    pub url: String,
303    pub progress: f64,
304    pub teams: Vec<RtProjectTeam>,
305    pub members: Vec<RtProjectMember>,
306    pub labels: Vec<RtProjectLabel>,
307}
308
309impl From<crate::db::Project> for RtProject {
310    fn from(project: crate::db::Project) -> Self {
311        Self {
312            id: project.id,
313            workspace_id: project.workspace_id,
314            slug_id: project.slug_id,
315            name: project.name,
316            description: project.description,
317            content: project.content,
318            icon: project.icon,
319            color: project.color,
320            status_id: project.status_id,
321            status_name: project.status_name,
322            status_type: project.status_type,
323            status_color: project.status_color,
324            priority: project.priority,
325            start_date: project.start_date,
326            target_date: project.target_date,
327            lead_id: project.lead_id,
328            lead_name: project.lead_name,
329            created_at: project.created_at,
330            updated_at: project.updated_at,
331            archived_at: project.archived_at,
332            url: project.url,
333            progress: project.progress,
334            teams: project
335                .teams
336                .into_iter()
337                .map(|team| RtProjectTeam {
338                    id: team.id,
339                    key: team.key,
340                    name: team.name,
341                })
342                .collect(),
343            members: project
344                .members
345                .into_iter()
346                .map(|member| RtProjectMember {
347                    id: member.id,
348                    name: member.name,
349                })
350                .collect(),
351            labels: project
352                .labels
353                .into_iter()
354                .map(|label| RtProjectLabel {
355                    id: label.id,
356                    name: label.name,
357                    color: label.color,
358                    description: label.description,
359                })
360                .collect(),
361        }
362    }
363}
364
365#[derive(uniffi::Record)]
366pub struct RtProjectMilestone {
367    pub id: String,
368    pub workspace_id: String,
369    pub project_id: String,
370    pub project_name: String,
371    pub name: String,
372    pub description: Option<String>,
373    pub target_date: Option<String>,
374    pub status: String,
375    pub progress: f64,
376    pub sort_order: f64,
377    pub created_at: String,
378    pub updated_at: String,
379    pub archived_at: Option<String>,
380}
381
382impl From<crate::db::ProjectMilestone> for RtProjectMilestone {
383    fn from(milestone: crate::db::ProjectMilestone) -> Self {
384        Self {
385            id: milestone.id,
386            workspace_id: milestone.workspace_id,
387            project_id: milestone.project_id,
388            project_name: milestone.project_name,
389            name: milestone.name,
390            description: milestone.description,
391            target_date: milestone.target_date,
392            status: milestone.status,
393            progress: milestone.progress,
394            sort_order: milestone.sort_order,
395            created_at: milestone.created_at,
396            updated_at: milestone.updated_at,
397            archived_at: milestone.archived_at,
398        }
399    }
400}
401
402#[derive(uniffi::Record)]
403pub struct RtProjectBundle {
404    pub project: RtProject,
405    pub milestones: Vec<RtProjectMilestone>,
406    pub issues: Vec<RtIssue>,
407}
408
409impl From<crate::db::ProjectBundle> for RtProjectBundle {
410    fn from(bundle: crate::db::ProjectBundle) -> Self {
411        Self {
412            project: bundle.project.into(),
413            milestones: bundle.milestones.into_iter().map(Into::into).collect(),
414            issues: bundle.issues.into_iter().map(Into::into).collect(),
415        }
416    }
417}
418
419#[derive(uniffi::Record)]
420pub struct RtProjectMilestoneBundle {
421    pub project: RtProject,
422    pub milestone: RtProjectMilestone,
423    pub issues: Vec<RtIssue>,
424}
425
426impl From<crate::db::ProjectMilestoneBundle> for RtProjectMilestoneBundle {
427    fn from(bundle: crate::db::ProjectMilestoneBundle) -> Self {
428        Self {
429            project: bundle.project.into(),
430            milestone: bundle.milestone.into(),
431            issues: bundle.issues.into_iter().map(Into::into).collect(),
432        }
433    }
434}
435
436#[derive(uniffi::Record)]
437pub struct RtProjectSyncResult {
438    pub projects: u64,
439    pub milestones: u64,
440}
441
442#[derive(uniffi::Record)]
443pub struct RtCreateProjectInput {
444    pub name: String,
445    pub team_ids: Vec<String>,
446    pub description: Option<String>,
447    pub content: Option<String>,
448    pub icon: Option<String>,
449    pub color: Option<String>,
450    pub status_id: Option<String>,
451    pub priority: Option<i32>,
452    pub lead_id: Option<String>,
453    pub start_date: Option<String>,
454    pub target_date: Option<String>,
455    pub member_ids: Option<Vec<String>>,
456    pub label_ids: Option<Vec<String>>,
457}
458
459#[derive(uniffi::Record)]
460pub struct RtUpdateProjectInput {
461    pub name: Option<String>,
462    pub team_ids: Option<Vec<String>>,
463    pub description: Option<String>,
464    pub content: Option<String>,
465    pub icon: Option<String>,
466    pub color: Option<String>,
467    pub status_id: Option<String>,
468    pub priority: Option<i32>,
469    pub lead_id: Option<String>,
470    pub start_date: Option<String>,
471    pub target_date: Option<String>,
472    pub member_ids: Option<Vec<String>>,
473    pub label_ids: Option<Vec<String>>,
474}
475
476#[derive(uniffi::Record)]
477pub struct RtCreateProjectMilestoneInput {
478    pub project_id: String,
479    pub name: String,
480    pub description: Option<String>,
481    pub target_date: Option<String>,
482    pub sort_order: Option<f64>,
483}
484
485#[derive(uniffi::Record)]
486pub struct RtUpdateProjectMilestoneInput {
487    pub project_id: Option<String>,
488    pub name: Option<String>,
489    pub description: Option<String>,
490    pub target_date: Option<String>,
491    pub sort_order: Option<f64>,
492}
493
494#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
495pub enum RtSyncPhase {
496    FetchingIssues,
497    GeneratingEmbeddings,
498    IndexingIssues,
499    IndexComplete,
500    HydratingIssueDetails,
501    HydratingLabels,
502    HydratingRelations,
503    HydratingComments,
504    WaitingForRateLimitRetry,
505}
506
507#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
508pub struct RtSyncProgress {
509    pub phase: RtSyncPhase,
510    pub completed: u64,
511    pub total: Option<u64>,
512    pub issue_id: Option<String>,
513}
514
515#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
516pub enum RtHydrationPolicy {
517    OpenOnly,
518    OpenAndRecent,
519    All,
520}
521
522impl From<RtHydrationPolicy> for crate::db::HydrationPolicy {
523    fn from(value: RtHydrationPolicy) -> Self {
524        match value {
525            RtHydrationPolicy::OpenOnly => Self::OpenOnly,
526            RtHydrationPolicy::OpenAndRecent => Self::OpenAndRecent,
527            RtHydrationPolicy::All => Self::All,
528        }
529    }
530}
531
532#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
533pub enum RtHydrationMode {
534    IfNeeded,
535    ForceRefresh,
536}
537
538impl From<RtHydrationMode> for crate::db::HydrationMode {
539    fn from(value: RtHydrationMode) -> Self {
540        match value {
541            RtHydrationMode::IfNeeded => Self::IfNeeded,
542            RtHydrationMode::ForceRefresh => Self::ForceRefresh,
543        }
544    }
545}
546
547#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
548pub enum RtHydrationResource {
549    Details,
550    Labels,
551    Relations,
552    Comments,
553}
554
555impl From<crate::db::HydrationResource> for RtHydrationResource {
556    fn from(value: crate::db::HydrationResource) -> Self {
557        match value {
558            crate::db::HydrationResource::Details => Self::Details,
559            crate::db::HydrationResource::Labels => Self::Labels,
560            crate::db::HydrationResource::Relations => Self::Relations,
561            crate::db::HydrationResource::Comments => Self::Comments,
562        }
563    }
564}
565
566#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
567pub enum RtHydrationStatus {
568    Pending,
569    Running,
570    Hydrated,
571    Partial,
572    Retryable,
573    PermissionDenied,
574    Unavailable,
575}
576
577impl From<crate::db::HydrationStatus> for RtHydrationStatus {
578    fn from(value: crate::db::HydrationStatus) -> Self {
579        match value {
580            crate::db::HydrationStatus::Pending => Self::Pending,
581            crate::db::HydrationStatus::Running => Self::Running,
582            crate::db::HydrationStatus::Hydrated => Self::Hydrated,
583            crate::db::HydrationStatus::Partial => Self::Partial,
584            crate::db::HydrationStatus::Retryable => Self::Retryable,
585            crate::db::HydrationStatus::PermissionDenied => Self::PermissionDenied,
586            crate::db::HydrationStatus::Unavailable => Self::Unavailable,
587        }
588    }
589}
590
591#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
592pub struct RtHydrationResourceState {
593    pub resource: RtHydrationResource,
594    pub status: RtHydrationStatus,
595    pub source_updated_at: String,
596    pub last_attempted_at: Option<String>,
597    pub hydrated_at: Option<String>,
598    pub attempt_count: u32,
599    pub next_retry_at: Option<String>,
600    pub last_error: Option<String>,
601}
602
603impl From<crate::db::HydrationResourceState> for RtHydrationResourceState {
604    fn from(value: crate::db::HydrationResourceState) -> Self {
605        Self {
606            resource: value.resource.into(),
607            status: value.status.into(),
608            source_updated_at: value.source_updated_at,
609            last_attempted_at: value.last_attempted_at,
610            hydrated_at: value.hydrated_at,
611            attempt_count: value.attempt_count,
612            next_retry_at: value.next_retry_at,
613            last_error: value.last_error,
614        }
615    }
616}
617
618#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
619pub struct RtIssueIndexSyncResult {
620    pub indexed: u64,
621    pub inserted: u64,
622    pub updated: u64,
623    pub unchanged: u64,
624    pub queued_for_hydration: u64,
625    pub committed_checkpoint: String,
626}
627
628impl From<crate::linear::IssueIndexSyncResult> for RtIssueIndexSyncResult {
629    fn from(value: crate::linear::IssueIndexSyncResult) -> Self {
630        Self {
631            indexed: value.indexed as u64,
632            inserted: value.inserted as u64,
633            updated: value.updated as u64,
634            unchanged: value.unchanged as u64,
635            queued_for_hydration: value.queued_for_hydration as u64,
636            committed_checkpoint: value.committed_checkpoint,
637        }
638    }
639}
640
641#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
642pub struct RtIssueHydrationResult {
643    pub issue_id: String,
644    pub status: RtHydrationStatus,
645    pub hydrated_resources: u64,
646    pub retryable_failures: u64,
647    pub permanent_failures: u64,
648    pub rate_limited: bool,
649    pub resources: Vec<RtHydrationResourceState>,
650}
651
652impl From<crate::linear::IssueHydrationResult> for RtIssueHydrationResult {
653    fn from(value: crate::linear::IssueHydrationResult) -> Self {
654        Self {
655            issue_id: value.issue_id,
656            status: value.status.into(),
657            hydrated_resources: value.hydrated_resources as u64,
658            retryable_failures: value.retryable_failures as u64,
659            permanent_failures: value.permanent_failures as u64,
660            rate_limited: value.rate_limited,
661            resources: value.resources.into_iter().map(Into::into).collect(),
662        }
663    }
664}
665
666#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
667pub struct RtHydrationBatchResult {
668    pub requested: u64,
669    pub hydrated: u64,
670    pub partial: u64,
671    pub deferred: u64,
672    pub retryable_failures: u64,
673    pub permanent_failures: u64,
674    pub required_failures: u64,
675    pub comment_failures: u64,
676    pub rate_limited: bool,
677}
678
679impl From<crate::linear::HydrationBatchResult> for RtHydrationBatchResult {
680    fn from(value: crate::linear::HydrationBatchResult) -> Self {
681        Self {
682            requested: value.requested as u64,
683            hydrated: value.hydrated as u64,
684            partial: value.partial as u64,
685            deferred: value.deferred as u64,
686            retryable_failures: value.retryable_failures as u64,
687            permanent_failures: value.permanent_failures as u64,
688            required_failures: value.required_failures as u64,
689            comment_failures: value.comment_failures as u64,
690            rate_limited: value.rate_limited,
691        }
692    }
693}
694
695fn rt_progress_phase(value: crate::linear::SyncProgressPhase) -> RtSyncPhase {
696    match value {
697        crate::linear::SyncProgressPhase::IndexingIssues => RtSyncPhase::IndexingIssues,
698        crate::linear::SyncProgressPhase::IndexComplete => RtSyncPhase::IndexComplete,
699        crate::linear::SyncProgressPhase::HydratingIssueDetails => {
700            RtSyncPhase::HydratingIssueDetails
701        }
702        crate::linear::SyncProgressPhase::HydratingLabels => RtSyncPhase::HydratingLabels,
703        crate::linear::SyncProgressPhase::HydratingRelations => RtSyncPhase::HydratingRelations,
704        crate::linear::SyncProgressPhase::HydratingComments => RtSyncPhase::HydratingComments,
705        crate::linear::SyncProgressPhase::WaitingForRateLimitRetry => {
706            RtSyncPhase::WaitingForRateLimitRetry
707        }
708    }
709}
710
711impl From<crate::db::TeamSummary> for RtTeamSummary {
712    fn from(t: crate::db::TeamSummary) -> Self {
713        Self {
714            key: t.key,
715            issue_count: t.issue_count as u64,
716            embedded_count: t.embedded_count as u64,
717            last_synced_at: t.last_synced_at,
718        }
719    }
720}
721
722impl From<RtSearchMode> for search::SearchMode {
723    fn from(mode: RtSearchMode) -> Self {
724        match mode {
725            RtSearchMode::Fts => search::SearchMode::Fts,
726            RtSearchMode::Vector => search::SearchMode::Vector,
727            RtSearchMode::Hybrid => search::SearchMode::Hybrid,
728        }
729    }
730}
731
732// ── Engine ───────────────────────────────────────────────────────────
733
734#[derive(uniffi::Object)]
735pub struct RectilinearEngine {
736    db: Database,
737    gemini_api_key: Option<String>,
738    sync_progress: Mutex<Option<RtSyncProgress>>,
739    /// Lazily initialized on first async call so it's created inside
740    /// UniFFI's Tokio runtime, binding hyper's DNS resolver to a live reactor.
741    http_client: OnceCell<reqwest::Client>,
742}
743
744/// Clears observable progress when an async operation returns, errors, or is
745/// canceled and its future is dropped.
746struct SyncProgressReset<'a>(&'a Mutex<Option<RtSyncProgress>>);
747
748impl Drop for SyncProgressReset<'_> {
749    fn drop(&mut self) {
750        *self.0.lock().unwrap() = None;
751    }
752}
753
754impl RectilinearEngine {
755    /// Get or create the HTTP client. Lazily initialized so it's created
756    /// inside the caller's Tokio runtime (UniFFI's), binding hyper's DNS
757    /// resolver to a live reactor.
758    async fn client(&self) -> &reqwest::Client {
759        self.http_client
760            .get_or_init(|| async { reqwest::Client::new() })
761            .await
762    }
763}
764
765#[uniffi::export(async_runtime = "tokio")]
766impl RectilinearEngine {
767    /// Create a new engine with an explicit database path and optional Gemini API key.
768    /// Linear API keys are resolved per-workspace from config.
769    #[uniffi::constructor]
770    pub fn new(db_path: String, gemini_api_key: Option<String>) -> Result<Self, RectilinearError> {
771        let path = Path::new(&db_path);
772        if let Some(parent) = path.parent() {
773            std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
774                message: format!("Failed to create database directory: {e}"),
775            })?;
776        }
777
778        let db = Database::open(path)?;
779
780        Ok(Self {
781            db,
782            gemini_api_key,
783            sync_progress: Mutex::new(None),
784            http_client: OnceCell::new(),
785        })
786    }
787
788    /// Resolve the Linear API key for a given workspace from config.
789    pub fn linear_api_key_for_workspace(
790        &self,
791        workspace_id: &str,
792    ) -> Result<String, RectilinearError> {
793        let config = Config::load().map_err(|e| RectilinearError::Config {
794            message: e.to_string(),
795        })?;
796        config
797            .workspace_api_key(workspace_id)
798            .map_err(|e| RectilinearError::Config {
799                message: e.to_string(),
800            })
801    }
802
803    /// List all configured workspace names.
804    pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
805        let config = Config::load().map_err(|e| RectilinearError::Config {
806            message: e.to_string(),
807        })?;
808        Ok(config.workspace_names())
809    }
810
811    /// Get the active workspace name.
812    pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
813        let config = Config::load().map_err(|e| RectilinearError::Config {
814            message: e.to_string(),
815        })?;
816        config
817            .resolve_active_workspace()
818            .map_err(|e| RectilinearError::Config {
819                message: e.to_string(),
820            })
821    }
822
823    // ── Sync methods (database reads, fast) ──────────────────────
824
825    /// Look up an issue by UUID or identifier (e.g. "CUT-123").
826    pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
827        Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
828    }
829
830    /// List cached projects. Call sync_projects first when fresh metadata is required.
831    pub fn list_projects(
832        &self,
833        workspace_id: String,
834        include_archived: bool,
835    ) -> Result<Vec<RtProject>, RectilinearError> {
836        Ok(self
837            .db
838            .list_projects(&workspace_id, include_archived)?
839            .into_iter()
840            .map(Into::into)
841            .collect())
842    }
843
844    /// Get cached project metadata by UUID, slug, or name.
845    pub fn get_project(
846        &self,
847        id_or_name: String,
848        workspace_id: String,
849    ) -> Result<Option<RtProject>, RectilinearError> {
850        Ok(self
851            .db
852            .get_project(&workspace_id, &id_or_name)?
853            .map(Into::into))
854    }
855
856    /// List cached milestones for a project UUID.
857    pub fn list_project_milestones(
858        &self,
859        project_id: String,
860    ) -> Result<Vec<RtProjectMilestone>, RectilinearError> {
861        Ok(self
862            .db
863            .list_project_milestones(&project_id)?
864            .into_iter()
865            .map(Into::into)
866            .collect())
867    }
868
869    /// Get a cached project bundle, including milestones and linked issues.
870    pub fn get_project_bundle(
871        &self,
872        id_or_name: String,
873        workspace_id: String,
874    ) -> Result<Option<RtProjectBundle>, RectilinearError> {
875        Ok(self
876            .db
877            .get_project_bundle(&workspace_id, &id_or_name)?
878            .map(Into::into))
879    }
880
881    /// Get a cached milestone bundle, including its project and linked issues.
882    pub fn get_project_milestone_bundle(
883        &self,
884        id_or_name: String,
885        project_id: Option<String>,
886        workspace_id: String,
887    ) -> Result<Option<RtProjectMilestoneBundle>, RectilinearError> {
888        Ok(self
889            .db
890            .get_project_milestone_bundle(&workspace_id, &id_or_name, project_id.as_deref())?
891            .map(Into::into))
892    }
893
894    /// Get unprioritized issues for triage.
895    pub fn get_triage_queue(
896        &self,
897        team: Option<String>,
898        include_completed: bool,
899        workspace_id: String,
900    ) -> Result<Vec<RtIssue>, RectilinearError> {
901        let issues =
902            self.db
903                .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
904        Ok(issues.into_iter().map(RtIssue::from).collect())
905    }
906
907    /// Full-text search (FTS5, BM25 ranking). Synchronous — hits local SQLite only.
908    pub fn search_fts(
909        &self,
910        query: String,
911        limit: u32,
912        workspace_id: String,
913    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
914        let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
915        Ok(results
916            .into_iter()
917            .map(|fts| RtSearchResult {
918                issue_id: fts.issue_id,
919                identifier: fts.identifier,
920                title: fts.title,
921                state_name: fts.state_name,
922                priority: fts.priority,
923                score: fts.bm25_score,
924                similarity: None,
925            })
926            .collect())
927    }
928
929    /// Count issues in the local database.
930    pub fn count_issues(
931        &self,
932        team: Option<String>,
933        workspace_id: String,
934    ) -> Result<u64, RectilinearError> {
935        Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
936    }
937
938    /// Count issues that have at least one embedding chunk.
939    pub fn count_embedded_issues(
940        &self,
941        team: Option<String>,
942        workspace_id: String,
943    ) -> Result<u64, RectilinearError> {
944        Ok(self
945            .db
946            .count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
947    }
948
949    /// Return the current sync progress, if a sync or embedding pass is active.
950    pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
951        self.sync_progress.lock().unwrap().clone()
952    }
953
954    /// Get field completeness counts in a single query.
955    pub fn get_field_completeness(
956        &self,
957        team: Option<String>,
958        workspace_id: String,
959    ) -> Result<RtFieldCompleteness, RectilinearError> {
960        let (total, desc, pri, labels, proj) = self
961            .db
962            .get_field_completeness(team.as_deref(), &workspace_id)?;
963        Ok(RtFieldCompleteness {
964            total: total as u64,
965            with_description: desc as u64,
966            with_priority: pri as u64,
967            with_labels: labels as u64,
968            with_project: proj as u64,
969        })
970    }
971
972    /// List all issues with lightweight summary data. Supports pagination and filtering.
973    pub fn list_all_issues(
974        &self,
975        team: Option<String>,
976        filter: Option<String>,
977        limit: u32,
978        offset: u32,
979        workspace_id: String,
980    ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
981        let issues = self.db.list_all_issues(
982            team.as_deref(),
983            filter.as_deref(),
984            limit as usize,
985            offset as usize,
986            &workspace_id,
987        )?;
988        Ok(issues.into_iter().map(RtIssueSummary::from).collect())
989    }
990
991    /// List teams with synced issues and their embedding coverage. Local-only, no network.
992    pub fn list_synced_teams(
993        &self,
994        workspace_id: String,
995    ) -> Result<Vec<RtTeamSummary>, RectilinearError> {
996        Ok(self
997            .db
998            .list_synced_teams(&workspace_id)?
999            .into_iter()
1000            .map(RtTeamSummary::from)
1001            .collect())
1002    }
1003
1004    /// Get enriched relations for an issue.
1005    pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
1006        Ok(self
1007            .db
1008            .get_relations_enriched(&issue_id)?
1009            .into_iter()
1010            .map(RtRelation::from)
1011            .collect())
1012    }
1013
1014    /// Get issues filtered by team and state types, enriched with blocker info.
1015    pub fn get_active_issues(
1016        &self,
1017        team: String,
1018        state_types: Vec<String>,
1019        workspace_id: String,
1020    ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
1021        let issues = self
1022            .db
1023            .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
1024        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
1025        let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
1026
1027        // Group blockers by issue ID
1028        let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
1029            std::collections::HashMap::new();
1030        for b in blockers {
1031            let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
1032            blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
1033                identifier: b.identifier,
1034                title: b.title,
1035                state_name: b.state_name,
1036                is_terminal,
1037            });
1038        }
1039
1040        Ok(issues
1041            .into_iter()
1042            .map(|issue| {
1043                let labels: Vec<String> =
1044                    serde_json::from_str(&issue.labels_json).unwrap_or_default();
1045                let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
1046                RtIssueEnriched {
1047                    id: issue.id,
1048                    identifier: issue.identifier,
1049                    team_key: issue.team_key,
1050                    title: issue.title,
1051                    description: issue.description,
1052                    state_name: issue.state_name,
1053                    state_type: issue.state_type,
1054                    priority: issue.priority,
1055                    assignee_name: issue.assignee_name,
1056                    project_name: issue.project_name,
1057                    project_id: issue.project_id,
1058                    project_milestone_id: issue.project_milestone_id,
1059                    project_milestone_name: issue.project_milestone_name,
1060                    cycle_id: issue.cycle_id,
1061                    cycle_name: issue.cycle_name,
1062                    labels,
1063                    created_at: issue.created_at,
1064                    updated_at: issue.updated_at,
1065                    url: issue.url,
1066                    branch_name: issue.branch_name,
1067                    blocked_by,
1068                }
1069            })
1070            .collect())
1071    }
1072
1073    // ── Async methods (network I/O) ─────────────────────────────
1074
1075    /// List all teams from Linear.
1076    pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
1077        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1078        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1079        let teams = client
1080            .list_teams()
1081            .await
1082            .map_err(|e| RectilinearError::Api {
1083                message: e.to_string(),
1084            })?;
1085        Ok(teams
1086            .into_iter()
1087            .map(|t| RtTeam {
1088                id: t.id,
1089                key: t.key,
1090                name: t.name,
1091            })
1092            .collect())
1093    }
1094
1095    /// Validate the configured Gemini API key without generating embeddings.
1096    pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
1097        let api_key = self
1098            .gemini_api_key
1099            .as_deref()
1100            .ok_or_else(|| RectilinearError::Config {
1101                message: "Gemini API key not configured".into(),
1102            })?;
1103
1104        crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
1105            .map_err(|e| RectilinearError::Config {
1106                message: e.to_string(),
1107            })?
1108            .test_api_key()
1109            .await
1110            .map_err(|e| RectilinearError::Api {
1111                message: e.to_string(),
1112            })
1113    }
1114
1115    /// Sync issues from Linear for a team. Returns the number of issues synced.
1116    pub async fn sync_projects(
1117        &self,
1118        workspace_id: String,
1119    ) -> Result<RtProjectSyncResult, RectilinearError> {
1120        let client = self.linear_client(&workspace_id).await?;
1121        let (projects, milestones) = client
1122            .sync_projects(&self.db, &workspace_id)
1123            .await
1124            .map_err(api_error)?;
1125        Ok(RtProjectSyncResult {
1126            projects: projects as u64,
1127            milestones: milestones as u64,
1128        })
1129    }
1130
1131    /// Synchronize and reconcile project metadata and milestones for one team.
1132    pub async fn sync_team_projects(
1133        &self,
1134        team_key: String,
1135        workspace_id: String,
1136    ) -> Result<RtProjectSyncResult, RectilinearError> {
1137        let client = self.linear_client(&workspace_id).await?;
1138        let result = client
1139            .sync_team_projects(&self.db, &team_key, &workspace_id)
1140            .await
1141            .map_err(api_error)?;
1142        Ok(RtProjectSyncResult {
1143            projects: result.projects as u64,
1144            milestones: result.milestones as u64,
1145        })
1146    }
1147
1148    /// Import and return a complete project hierarchy with every linked issue.
1149    pub async fn import_project(
1150        &self,
1151        id_or_name: String,
1152        workspace_id: String,
1153    ) -> Result<RtProjectBundle, RectilinearError> {
1154        let client = self.linear_client(&workspace_id).await?;
1155        client
1156            .import_project(&self.db, &workspace_id, &id_or_name)
1157            .await
1158            .map(Into::into)
1159            .map_err(api_error)
1160    }
1161
1162    /// Import and return a complete milestone hierarchy with every linked issue.
1163    pub async fn import_project_milestone(
1164        &self,
1165        id_or_name: String,
1166        project_id: Option<String>,
1167        workspace_id: String,
1168    ) -> Result<RtProjectMilestoneBundle, RectilinearError> {
1169        let client = self.linear_client(&workspace_id).await?;
1170        client
1171            .import_project_milestone(&self.db, &workspace_id, project_id.as_deref(), &id_or_name)
1172            .await
1173            .map(Into::into)
1174            .map_err(api_error)
1175    }
1176
1177    /// Create a project. Relationship fields use Linear model UUIDs.
1178    pub async fn create_project(
1179        &self,
1180        input: RtCreateProjectInput,
1181        workspace_id: String,
1182    ) -> Result<RtProject, RectilinearError> {
1183        let client = self.linear_client(&workspace_id).await?;
1184        let input = crate::linear::CreateProjectInput {
1185            name: input.name,
1186            team_ids: input.team_ids,
1187            description: input.description,
1188            content: input.content,
1189            icon: input.icon,
1190            color: input.color,
1191            status_id: input.status_id,
1192            priority: input.priority,
1193            lead_id: input.lead_id,
1194            start_date: input.start_date,
1195            target_date: input.target_date,
1196            member_ids: input.member_ids,
1197            label_ids: input.label_ids,
1198        };
1199        let id = client.create_project(&input).await.map_err(api_error)?;
1200        let project = client
1201            .fetch_project(&id, &workspace_id)
1202            .await
1203            .map_err(api_error)?;
1204        self.db.upsert_project(&project)?;
1205        Ok(project.into())
1206    }
1207
1208    /// Update a project. Empty strings clear nullable metadata fields.
1209    pub async fn update_project(
1210        &self,
1211        id_or_name: String,
1212        input: RtUpdateProjectInput,
1213        workspace_id: String,
1214    ) -> Result<RtProject, RectilinearError> {
1215        let client = self.linear_client(&workspace_id).await?;
1216        let id = client
1217            .find_project_by_name(&id_or_name)
1218            .await
1219            .map_err(api_error)?;
1220        let input = crate::linear::UpdateProjectInput {
1221            name: input.name,
1222            team_ids: input.team_ids,
1223            description: input.description,
1224            content: input.content,
1225            icon: input.icon,
1226            color: input.color,
1227            status_id: input.status_id,
1228            priority: input.priority,
1229            lead_id: input.lead_id,
1230            start_date: input.start_date,
1231            target_date: input.target_date,
1232            member_ids: input.member_ids,
1233            label_ids: input.label_ids,
1234        };
1235        client
1236            .update_project(&id, &input)
1237            .await
1238            .map_err(api_error)?;
1239        let project = client
1240            .fetch_project(&id, &workspace_id)
1241            .await
1242            .map_err(api_error)?;
1243        self.db.upsert_project(&project)?;
1244        Ok(project.into())
1245    }
1246
1247    /// Delete (archive) a project in Linear and remove its cached hierarchy.
1248    pub async fn delete_project(
1249        &self,
1250        id_or_name: String,
1251        workspace_id: String,
1252    ) -> Result<(), RectilinearError> {
1253        let client = self.linear_client(&workspace_id).await?;
1254        let id = client
1255            .find_project_by_name(&id_or_name)
1256            .await
1257            .map_err(api_error)?;
1258        client.delete_project(&id).await.map_err(api_error)?;
1259        self.db.delete_project_local(&id)?;
1260        Ok(())
1261    }
1262
1263    /// Create a project milestone. The project_id must be a Linear project UUID.
1264    pub async fn create_project_milestone(
1265        &self,
1266        input: RtCreateProjectMilestoneInput,
1267        workspace_id: String,
1268    ) -> Result<RtProjectMilestone, RectilinearError> {
1269        let client = self.linear_client(&workspace_id).await?;
1270        let input = crate::linear::CreateProjectMilestoneInput {
1271            project_id: input.project_id,
1272            name: input.name,
1273            description: input.description,
1274            target_date: input.target_date,
1275            sort_order: input.sort_order,
1276        };
1277        let id = client
1278            .create_project_milestone(&input)
1279            .await
1280            .map_err(api_error)?;
1281        self.cache_project_milestone(&client, &id, &workspace_id)
1282            .await
1283    }
1284
1285    /// Update or move a project milestone.
1286    pub async fn update_project_milestone(
1287        &self,
1288        id_or_name: String,
1289        owning_project_id: Option<String>,
1290        input: RtUpdateProjectMilestoneInput,
1291        workspace_id: String,
1292    ) -> Result<RtProjectMilestone, RectilinearError> {
1293        let client = self.linear_client(&workspace_id).await?;
1294        let id = client
1295            .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1296            .await
1297            .map_err(api_error)?;
1298        let input = crate::linear::UpdateProjectMilestoneInput {
1299            project_id: input.project_id,
1300            name: input.name,
1301            description: input.description,
1302            target_date: input.target_date,
1303            sort_order: input.sort_order,
1304        };
1305        client
1306            .update_project_milestone(&id, &input)
1307            .await
1308            .map_err(api_error)?;
1309        self.cache_project_milestone(&client, &id, &workspace_id)
1310            .await
1311    }
1312
1313    /// Delete a project milestone in Linear and remove it from the local hierarchy.
1314    pub async fn delete_project_milestone(
1315        &self,
1316        id_or_name: String,
1317        owning_project_id: Option<String>,
1318        workspace_id: String,
1319    ) -> Result<(), RectilinearError> {
1320        let client = self.linear_client(&workspace_id).await?;
1321        let id = client
1322            .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1323            .await
1324            .map_err(api_error)?;
1325        client
1326            .delete_project_milestone(&id)
1327            .await
1328            .map_err(api_error)?;
1329        self.db.delete_project_milestone_local(&id)?;
1330        Ok(())
1331    }
1332
1333    /// Replace an issue's project and milestone relationship. Passing nil clears that relationship.
1334    pub async fn set_issue_project_context(
1335        &self,
1336        issue_id: String,
1337        project_id: Option<String>,
1338        project_milestone_id: Option<String>,
1339        workspace_id: String,
1340    ) -> Result<RtIssue, RectilinearError> {
1341        let client = self.linear_client(&workspace_id).await?;
1342        let project_id = match (project_id, project_milestone_id.as_deref()) {
1343            (Some(project_id), _) => Some(project_id),
1344            (None, Some(milestone_id)) => Some(
1345                client
1346                    .fetch_project_milestone(milestone_id, &workspace_id)
1347                    .await
1348                    .map_err(api_error)?
1349                    .project_id,
1350            ),
1351            (None, None) => None,
1352        };
1353        let project_value = project_id.unwrap_or_default();
1354        let milestone_value = project_milestone_id.unwrap_or_default();
1355        client
1356            .update_issue(
1357                &issue_id,
1358                crate::linear::UpdateIssueInput {
1359                    project_id: Some(&project_value),
1360                    project_milestone_id: Some(&milestone_value),
1361                    ..Default::default()
1362                },
1363            )
1364            .await
1365            .map_err(api_error)?;
1366        let (mut issue, relations, label_ids) = client
1367            .fetch_single_issue(&issue_id)
1368            .await
1369            .map_err(api_error)?;
1370        issue.workspace_id = workspace_id;
1371        self.db.upsert_issue(&issue)?;
1372        self.db.upsert_relations(&issue.id, &relations)?;
1373        self.db.replace_issue_labels(&issue.id, &label_ids)?;
1374        Ok(issue.into())
1375    }
1376
1377    /// Sync issues from Linear for a team. Returns the number of issues synced.
1378    pub async fn sync_team(
1379        &self,
1380        team_key: String,
1381        full: bool,
1382        workspace_id: String,
1383    ) -> Result<u64, RectilinearError> {
1384        self.set_sync_progress(Some(RtSyncProgress {
1385            phase: RtSyncPhase::FetchingIssues,
1386            completed: 0,
1387            total: None,
1388            issue_id: None,
1389        }));
1390        let _progress_reset = SyncProgressReset(&self.sync_progress);
1391
1392        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1393        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1394        let progress_state = &self.sync_progress;
1395        let progress = move |count: usize| {
1396            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1397                phase: RtSyncPhase::FetchingIssues,
1398                completed: count as u64,
1399                total: None,
1400                issue_id: None,
1401            });
1402        };
1403        let result = client
1404            .sync_team(
1405                &self.db,
1406                &team_key,
1407                &workspace_id,
1408                full,
1409                false,
1410                Some(&progress),
1411            )
1412            .await
1413            .map_err(|e| RectilinearError::Api {
1414                message: e.to_string(),
1415            });
1416        self.set_sync_progress(None);
1417        result.map(|count| count as u64)
1418    }
1419
1420    /// Fast authoritative issue-index sync. Rich fields may remain nil until
1421    /// one of the hydration methods completes.
1422    pub async fn sync_team_index(
1423        &self,
1424        team_key: String,
1425        full: bool,
1426        workspace_id: String,
1427    ) -> Result<RtIssueIndexSyncResult, RectilinearError> {
1428        let client = self.linear_client(&workspace_id).await?;
1429        self.set_sync_progress(Some(RtSyncProgress {
1430            phase: RtSyncPhase::IndexingIssues,
1431            completed: 0,
1432            total: None,
1433            issue_id: None,
1434        }));
1435        let _progress_reset = SyncProgressReset(&self.sync_progress);
1436        let progress_state = &self.sync_progress;
1437        let progress = move |update: crate::linear::SyncProgressUpdate| {
1438            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1439                phase: rt_progress_phase(update.phase),
1440                completed: update.completed as u64,
1441                total: update.total.map(|value| value as u64),
1442                issue_id: update.issue_id,
1443            });
1444        };
1445        let result = client
1446            .sync_team_index(&self.db, &team_key, &workspace_id, full, Some(&progress))
1447            .await
1448            .map(Into::into)
1449            .map_err(api_error);
1450        self.set_sync_progress(None);
1451        result
1452    }
1453
1454    /// Compatibility API: force-refresh all rich resource families.
1455    pub async fn hydrate_issue(
1456        &self,
1457        issue_id: String,
1458        workspace_id: String,
1459    ) -> Result<RtIssueHydrationResult, RectilinearError> {
1460        let client = self.linear_client(&workspace_id).await?;
1461        let _progress_reset = SyncProgressReset(&self.sync_progress);
1462        let progress_state = &self.sync_progress;
1463        let progress = move |update: crate::linear::SyncProgressUpdate| {
1464            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1465                phase: rt_progress_phase(update.phase),
1466                completed: update.completed as u64,
1467                total: update.total.map(|value| value as u64),
1468                issue_id: update.issue_id,
1469            });
1470        };
1471        let result = client
1472            .hydrate_issue(&self.db, &issue_id, &workspace_id, Some(&progress))
1473            .await
1474            .map(Into::into)
1475            .map_err(api_error);
1476        self.set_sync_progress(None);
1477        result
1478    }
1479
1480    /// Hydrate a selected issue only when needed, or explicitly force-refresh it.
1481    pub async fn hydrate_issue_with_mode(
1482        &self,
1483        issue_id: String,
1484        workspace_id: String,
1485        mode: RtHydrationMode,
1486    ) -> Result<RtIssueHydrationResult, RectilinearError> {
1487        let client = self.linear_client(&workspace_id).await?;
1488        let _progress_reset = SyncProgressReset(&self.sync_progress);
1489        let progress_state = &self.sync_progress;
1490        let progress = move |update: crate::linear::SyncProgressUpdate| {
1491            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1492                phase: rt_progress_phase(update.phase),
1493                completed: update.completed as u64,
1494                total: update.total.map(|value| value as u64),
1495                issue_id: update.issue_id,
1496            });
1497        };
1498        let result = client
1499            .hydrate_issue_with_mode(
1500                &self.db,
1501                &issue_id,
1502                &workspace_id,
1503                mode.into(),
1504                Some(&progress),
1505            )
1506            .await
1507            .map(Into::into)
1508            .map_err(api_error);
1509        self.set_sync_progress(None);
1510        result
1511    }
1512
1513    /// Hydrate a bounded deterministic background batch.
1514    pub async fn hydrate_pending_issues(
1515        &self,
1516        team_key: String,
1517        workspace_id: String,
1518        limit: u32,
1519        policy: RtHydrationPolicy,
1520    ) -> Result<RtHydrationBatchResult, RectilinearError> {
1521        let client = self.linear_client(&workspace_id).await?;
1522        let _progress_reset = SyncProgressReset(&self.sync_progress);
1523        let progress_state = &self.sync_progress;
1524        let progress = move |update: crate::linear::SyncProgressUpdate| {
1525            *progress_state.lock().unwrap() = Some(RtSyncProgress {
1526                phase: rt_progress_phase(update.phase),
1527                completed: update.completed as u64,
1528                total: update.total.map(|value| value as u64),
1529                issue_id: update.issue_id,
1530            });
1531        };
1532        let result = client
1533            .hydrate_pending_issues(
1534                &self.db,
1535                &team_key,
1536                &workspace_id,
1537                limit as usize,
1538                policy.into(),
1539                Some(&progress),
1540            )
1541            .await
1542            .map(Into::into)
1543            .map_err(api_error);
1544        self.set_sync_progress(None);
1545        result
1546    }
1547
1548    /// Read persisted hydration state without contacting Linear.
1549    pub fn get_issue_hydration_state(
1550        &self,
1551        issue_id: String,
1552        workspace_id: String,
1553    ) -> Result<RtIssueHydrationResult, RectilinearError> {
1554        let issue = self
1555            .db
1556            .get_issue(&issue_id)?
1557            .ok_or_else(|| RectilinearError::NotFound {
1558                key: issue_id.clone(),
1559            })?;
1560        let state = self
1561            .db
1562            .get_issue_hydration_state(&workspace_id, &issue.id)?;
1563        Ok(RtIssueHydrationResult {
1564            issue_id: issue.id,
1565            status: state.status.into(),
1566            hydrated_resources: state
1567                .resources
1568                .iter()
1569                .filter(|resource| resource.status == crate::db::HydrationStatus::Hydrated)
1570                .count() as u64,
1571            retryable_failures: state
1572                .resources
1573                .iter()
1574                .filter(|resource| resource.status == crate::db::HydrationStatus::Retryable)
1575                .count() as u64,
1576            permanent_failures: state
1577                .resources
1578                .iter()
1579                .filter(|resource| {
1580                    matches!(
1581                        resource.status,
1582                        crate::db::HydrationStatus::PermissionDenied
1583                            | crate::db::HydrationStatus::Unavailable
1584                    )
1585                })
1586                .count() as u64,
1587            rate_limited: false,
1588            resources: state.resources.into_iter().map(Into::into).collect(),
1589        })
1590    }
1591
1592    /// Hybrid search (FTS + vector via RRF). Requires embedder for vector component.
1593    pub async fn search_hybrid(
1594        &self,
1595        query: String,
1596        team: Option<String>,
1597        limit: u32,
1598        workspace_id: String,
1599    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1600        let config = Config::load().unwrap_or_default();
1601        let embedder = self.make_embedder(&config).await?;
1602
1603        let results = search::search(
1604            &self.db,
1605            search::SearchParams {
1606                query: &query,
1607                mode: search::SearchMode::Hybrid,
1608                team_key: team.as_deref(),
1609                state_filter: None,
1610                label_ids: None,
1611                limit: limit as usize,
1612                embedder: embedder.as_ref(),
1613                rrf_k: config.search.rrf_k,
1614                workspace_id: &workspace_id,
1615            },
1616        )
1617        .await?;
1618
1619        Ok(results.into_iter().map(RtSearchResult::from).collect())
1620    }
1621
1622    /// Find potential duplicate issues by semantic similarity.
1623    pub async fn find_duplicates(
1624        &self,
1625        text: String,
1626        team: Option<String>,
1627        threshold: f32,
1628        workspace_id: String,
1629    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1630        let config = Config::load().unwrap_or_default();
1631        let embedder =
1632            self.make_embedder(&config)
1633                .await?
1634                .ok_or_else(|| RectilinearError::Config {
1635                    message:
1636                        "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
1637                            .into(),
1638                })?;
1639
1640        let results = search::find_duplicates(
1641            &self.db,
1642            &text,
1643            team.as_deref(),
1644            threshold,
1645            10,
1646            &embedder,
1647            config.search.rrf_k,
1648            &workspace_id,
1649        )
1650        .await?;
1651
1652        Ok(results.into_iter().map(RtSearchResult::from).collect())
1653    }
1654
1655    /// Update an issue in Linear (title, description, priority, state, labels).
1656    pub async fn save_issue(
1657        &self,
1658        issue_id: String,
1659        title: Option<String>,
1660        description: Option<String>,
1661        priority: Option<i32>,
1662        state: Option<String>,
1663        labels: Option<Vec<String>>,
1664        workspace_id: String,
1665    ) -> Result<(), RectilinearError> {
1666        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1667        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1668
1669        let state_id = if let Some(ref state_name) = state {
1670            // Need to resolve state name → ID. Get team from issue first.
1671            if let Some(issue) = self.db.get_issue(&issue_id)? {
1672                Some(
1673                    client
1674                        .get_state_id(&issue.team_key, state_name)
1675                        .await
1676                        .map_err(|e| RectilinearError::Api {
1677                            message: e.to_string(),
1678                        })?,
1679                )
1680            } else {
1681                None
1682            }
1683        } else {
1684            None
1685        };
1686
1687        let label_ids =
1688            if let Some(ref label_names) = labels {
1689                Some(client.get_label_ids(label_names).await.map_err(|e| {
1690                    RectilinearError::Api {
1691                        message: e.to_string(),
1692                    }
1693                })?)
1694            } else {
1695                None
1696            };
1697
1698        client
1699            .update_issue(
1700                &issue_id,
1701                crate::linear::UpdateIssueInput {
1702                    title: title.as_deref(),
1703                    description: description.as_deref(),
1704                    priority,
1705                    state_id: state_id.as_deref(),
1706                    label_ids: label_ids.as_deref(),
1707                    ..Default::default()
1708                },
1709            )
1710            .await
1711            .map_err(|e| RectilinearError::Api {
1712                message: e.to_string(),
1713            })?;
1714
1715        // Re-sync the updated issue back to local DB
1716        if let Ok((issue, relations, label_ids)) = client.fetch_single_issue(&issue_id).await {
1717            let _ = self.db.upsert_issue(&issue);
1718            let _ = self.db.upsert_relations(&issue.id, &relations);
1719            let _ = self.db.replace_issue_labels(&issue.id, &label_ids);
1720        }
1721
1722        Ok(())
1723    }
1724
1725    /// Create a new issue in Linear and return its (id, identifier).
1726    pub async fn create_issue(
1727        &self,
1728        input: RtCreateIssueInput,
1729        workspace_id: String,
1730    ) -> Result<RtCreateIssueResult, RectilinearError> {
1731        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1732        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1733
1734        let team_id =
1735            client
1736                .get_team_id(&input.team_key)
1737                .await
1738                .map_err(|e| RectilinearError::Api {
1739                    message: e.to_string(),
1740                })?;
1741        let project_id = match (
1742            input.project_id.as_deref(),
1743            input.project_milestone_id.as_deref(),
1744        ) {
1745            (Some(project_id), _) => Some(project_id.to_string()),
1746            (None, Some(milestone_id)) => Some(
1747                client
1748                    .fetch_project_milestone(milestone_id, &workspace_id)
1749                    .await
1750                    .map_err(api_error)?
1751                    .project_id,
1752            ),
1753            (None, None) => None,
1754        };
1755
1756        let (id, identifier) = client
1757            .create_issue(crate::linear::CreateIssueInput {
1758                team_id: &team_id,
1759                title: &input.title,
1760                description: input.description.as_deref(),
1761                priority: input.priority,
1762                label_ids: &input.label_ids,
1763                assignee_id: None,
1764                parent_id: input.parent_id.as_deref(),
1765                project_id: project_id.as_deref(),
1766                project_milestone_id: input.project_milestone_id.as_deref(),
1767            })
1768            .await
1769            .map_err(|e| RectilinearError::Api {
1770                message: e.to_string(),
1771            })?;
1772
1773        Ok(RtCreateIssueResult { id, identifier })
1774    }
1775
1776    /// Add a comment to a Linear issue.
1777    pub async fn add_comment(
1778        &self,
1779        issue_id: String,
1780        body: String,
1781        workspace_id: String,
1782    ) -> Result<(), RectilinearError> {
1783        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1784        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1785        client
1786            .add_comment(&issue_id, &body)
1787            .await
1788            .map_err(|e| RectilinearError::Api {
1789                message: e.to_string(),
1790            })
1791    }
1792
1793    /// Fetch a single issue live from Linear and upsert into local DB.
1794    /// Accepts either a UUID or identifier (e.g. "CUT-123").
1795    pub async fn refresh_issue(
1796        &self,
1797        id_or_identifier: String,
1798        workspace_id: String,
1799    ) -> Result<Option<RtIssue>, RectilinearError> {
1800        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1801        let client = LinearClient::with_http_client(self.client().await.clone(), &api_key);
1802
1803        let result = if id_or_identifier.contains('-')
1804            && id_or_identifier
1805                .chars()
1806                .last()
1807                .is_some_and(|c| c.is_ascii_digit())
1808        {
1809            client
1810                .fetch_issue_by_identifier(&id_or_identifier)
1811                .await
1812                .map_err(|e| RectilinearError::Api {
1813                    message: e.to_string(),
1814                })?
1815        } else {
1816            Some(
1817                client
1818                    .fetch_single_issue(&id_or_identifier)
1819                    .await
1820                    .map_err(|e| RectilinearError::Api {
1821                        message: e.to_string(),
1822                    })?,
1823            )
1824        };
1825
1826        if let Some((issue, relations, label_ids)) = result {
1827            self.db.upsert_issue(&issue)?;
1828            self.db.upsert_relations(&issue.id, &relations)?;
1829            self.db.replace_issue_labels(&issue.id, &label_ids)?;
1830            Ok(Some(RtIssue::from(issue)))
1831        } else {
1832            Ok(None)
1833        }
1834    }
1835
1836    /// Generate embeddings for issues that don't have them yet.
1837    /// Returns the number of issues embedded.
1838    pub async fn embed_issues(
1839        &self,
1840        team: Option<String>,
1841        limit: u32,
1842        workspace_id: String,
1843    ) -> Result<u64, RectilinearError> {
1844        let config = Config::load().unwrap_or_default();
1845        let embedder =
1846            self.make_embedder(&config)
1847                .await?
1848                .ok_or_else(|| {
1849                    RectilinearError::Config {
1850                message:
1851                    "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
1852                        .into(),
1853            }
1854                })?;
1855
1856        let model_name = embedder.backend_name().to_string();
1857        let issues = self.db.get_issues_needing_embedding_for_model(
1858            team.as_deref(),
1859            false,
1860            &workspace_id,
1861            Some(&model_name),
1862        )?;
1863
1864        let to_process = if limit > 0 {
1865            &issues[..std::cmp::min(issues.len(), limit as usize)]
1866        } else {
1867            &issues
1868        };
1869        let total = to_process.len() as u64;
1870
1871        self.set_sync_progress(Some(RtSyncProgress {
1872            phase: RtSyncPhase::GeneratingEmbeddings,
1873            completed: 0,
1874            total: Some(total),
1875            issue_id: None,
1876        }));
1877
1878        // Collect chunks from multiple issues into batches to reduce API round-trips.
1879        // Each Gemini batchEmbedContents call handles up to 100 texts, so we fill
1880        // batches across issue boundaries rather than making one call per issue.
1881        const BATCH_SIZE: usize = 100;
1882
1883        // Pre-chunk issues whose model or exact source content is stale.
1884        struct IssueChunks {
1885            issue_id: String,
1886            source_content_hash: String,
1887            chunks: Vec<String>,
1888        }
1889        let mut pending: Vec<IssueChunks> = Vec::new();
1890        for issue in to_process {
1891            let chunks = crate::embedding::chunk_text(
1892                &issue.title,
1893                issue.description.as_deref().unwrap_or(""),
1894                512,
1895                64,
1896            );
1897            pending.push(IssueChunks {
1898                issue_id: issue.id.clone(),
1899                source_content_hash: crate::embedding::issue_content_hash(
1900                    &issue.title,
1901                    issue.description.as_deref(),
1902                ),
1903                chunks,
1904            });
1905        }
1906
1907        let result: Result<u64, RectilinearError> = async {
1908            // Flatten all chunks into a single list with back-references to their issue.
1909            // Each entry: (index into `pending`, chunk_index_within_issue, chunk_text)
1910            let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
1911            for (issue_idx, ic) in pending.iter().enumerate() {
1912                for (chunk_idx, text) in ic.chunks.iter().enumerate() {
1913                    flat_chunks.push((issue_idx, chunk_idx, text.clone()));
1914                }
1915            }
1916
1917            // Embed in batches of BATCH_SIZE across issue boundaries.
1918            let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
1919            for batch in flat_chunks.chunks(BATCH_SIZE) {
1920                let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
1921                let batch_embeddings =
1922                    embedder
1923                        .embed_batch(&texts)
1924                        .await
1925                        .map_err(|e| RectilinearError::Api {
1926                            message: e.to_string(),
1927                        })?;
1928                embeddings_flat.extend(batch_embeddings);
1929            }
1930
1931            // Re-group embeddings back to their issues and persist.
1932            let mut emb_offset = 0usize;
1933            let mut count = 0u64;
1934            for ic in &pending {
1935                let n = ic.chunks.len();
1936                let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
1937
1938                let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
1939                    .chunks
1940                    .iter()
1941                    .zip(issue_embeddings.iter())
1942                    .enumerate()
1943                    .map(|(idx, (text, emb))| {
1944                        (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
1945                    })
1946                    .collect();
1947
1948                self.db.upsert_chunks_with_model_and_hash(
1949                    &ic.issue_id,
1950                    &chunk_data,
1951                    &model_name,
1952                    &ic.source_content_hash,
1953                )?;
1954                emb_offset += n;
1955                count += 1;
1956                self.set_sync_progress(Some(RtSyncProgress {
1957                    phase: RtSyncPhase::GeneratingEmbeddings,
1958                    completed: count,
1959                    total: Some(total),
1960                    issue_id: None,
1961                }));
1962            }
1963
1964            Ok(count)
1965        }
1966        .await;
1967
1968        self.set_sync_progress(None);
1969        result
1970    }
1971}
1972
1973// ── Private helpers ──────────────────────────────────────────────────
1974
1975impl RectilinearEngine {
1976    async fn linear_client(&self, workspace_id: &str) -> Result<LinearClient, RectilinearError> {
1977        let api_key = self.linear_api_key_for_workspace(workspace_id)?;
1978        Ok(LinearClient::with_http_client(
1979            self.client().await.clone(),
1980            &api_key,
1981        ))
1982    }
1983
1984    async fn cache_project_milestone(
1985        &self,
1986        client: &LinearClient,
1987        milestone_id: &str,
1988        workspace_id: &str,
1989    ) -> Result<RtProjectMilestone, RectilinearError> {
1990        let milestone = client
1991            .fetch_project_milestone(milestone_id, workspace_id)
1992            .await
1993            .map_err(api_error)?;
1994        let project = client
1995            .fetch_project(&milestone.project_id, workspace_id)
1996            .await
1997            .map_err(api_error)?;
1998        self.db.upsert_project(&project)?;
1999        self.db.upsert_project_milestone(&milestone)?;
2000        Ok(milestone.into())
2001    }
2002
2003    fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
2004        *self.sync_progress.lock().unwrap() = progress;
2005    }
2006
2007    async fn make_embedder(
2008        &self,
2009        config: &Config,
2010    ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
2011        let key = self
2012            .gemini_api_key
2013            .as_deref()
2014            .or(config.embedding.gemini_api_key.as_deref());
2015
2016        if let Some(api_key) = key {
2017            Ok(Some(
2018                crate::embedding::Embedder::new_api_with_http_client(
2019                    self.client().await.clone(),
2020                    api_key,
2021                )
2022                .map_err(|e| RectilinearError::Config {
2023                    message: e.to_string(),
2024                })?,
2025            ))
2026        } else {
2027            #[cfg(feature = "local-embeddings")]
2028            {
2029                let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
2030                    message: e.to_string(),
2031                })?;
2032                Ok(Some(
2033                    crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
2034                        RectilinearError::Config {
2035                            message: e.to_string(),
2036                        }
2037                    })?,
2038                ))
2039            }
2040            #[cfg(not(feature = "local-embeddings"))]
2041            {
2042                Ok(None)
2043            }
2044        }
2045    }
2046}
2047
2048fn api_error(error: anyhow::Error) -> RectilinearError {
2049    RectilinearError::Api {
2050        message: error.to_string(),
2051    }
2052}