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 labels: Vec<String>,
52    pub created_at: String,
53    pub updated_at: String,
54    pub url: String,
55    pub branch_name: Option<String>,
56}
57
58impl From<crate::db::Issue> for RtIssue {
59    fn from(issue: crate::db::Issue) -> Self {
60        let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
61        Self {
62            id: issue.id,
63            identifier: issue.identifier,
64            team_key: issue.team_key,
65            title: issue.title,
66            description: issue.description,
67            state_name: issue.state_name,
68            state_type: issue.state_type,
69            priority: issue.priority,
70            assignee_name: issue.assignee_name,
71            project_name: issue.project_name,
72            labels,
73            created_at: issue.created_at,
74            updated_at: issue.updated_at,
75            url: issue.url,
76            branch_name: issue.branch_name,
77        }
78    }
79}
80
81#[derive(uniffi::Record)]
82pub struct RtSearchResult {
83    pub issue_id: String,
84    pub identifier: String,
85    pub title: String,
86    pub state_name: String,
87    pub priority: i32,
88    pub score: f64,
89    pub similarity: Option<f32>,
90}
91
92impl From<search::SearchResult> for RtSearchResult {
93    fn from(sr: search::SearchResult) -> Self {
94        Self {
95            issue_id: sr.issue_id,
96            identifier: sr.identifier,
97            title: sr.title,
98            state_name: sr.state_name,
99            priority: sr.priority,
100            score: sr.score,
101            similarity: sr.similarity,
102        }
103    }
104}
105
106#[derive(uniffi::Record)]
107pub struct RtRelation {
108    pub relation_type: String,
109    pub issue_identifier: String,
110    pub issue_title: String,
111    pub issue_state: String,
112    pub issue_url: String,
113}
114
115impl From<crate::db::EnrichedRelation> for RtRelation {
116    fn from(rel: crate::db::EnrichedRelation) -> Self {
117        Self {
118            relation_type: rel.relation_type,
119            issue_identifier: rel.issue_identifier,
120            issue_title: rel.issue_title,
121            issue_state: rel.issue_state,
122            issue_url: rel.issue_url,
123        }
124    }
125}
126
127#[derive(uniffi::Record)]
128pub struct RtBlocker {
129    pub identifier: String,
130    pub title: String,
131    pub state_name: String,
132    pub is_terminal: bool,
133}
134
135#[derive(uniffi::Record)]
136pub struct RtIssueEnriched {
137    pub id: String,
138    pub identifier: String,
139    pub team_key: String,
140    pub title: String,
141    pub description: Option<String>,
142    pub state_name: String,
143    pub state_type: String,
144    pub priority: i32,
145    pub assignee_name: Option<String>,
146    pub project_name: Option<String>,
147    pub labels: Vec<String>,
148    pub created_at: String,
149    pub updated_at: String,
150    pub url: String,
151    pub branch_name: Option<String>,
152    pub blocked_by: Vec<RtBlocker>,
153}
154
155#[derive(uniffi::Record)]
156pub struct RtTeam {
157    pub id: String,
158    pub key: String,
159    pub name: String,
160}
161
162#[derive(uniffi::Enum)]
163pub enum RtSearchMode {
164    Fts,
165    Vector,
166    Hybrid,
167}
168
169#[derive(uniffi::Record)]
170pub struct RtFieldCompleteness {
171    pub total: u64,
172    pub with_description: u64,
173    pub with_priority: u64,
174    pub with_labels: u64,
175    pub with_project: u64,
176}
177
178#[derive(uniffi::Record)]
179pub struct RtIssueSummary {
180    pub id: String,
181    pub identifier: String,
182    pub team_key: String,
183    pub title: String,
184    pub state_name: String,
185    pub state_type: String,
186    pub priority: i32,
187    pub project_name: Option<String>,
188    pub labels: Vec<String>,
189    pub updated_at: String,
190    pub url: String,
191    pub has_description: bool,
192    pub has_embedding: bool,
193}
194
195impl From<crate::db::IssueSummary> for RtIssueSummary {
196    fn from(s: crate::db::IssueSummary) -> Self {
197        Self {
198            id: s.id,
199            identifier: s.identifier,
200            team_key: s.team_key,
201            title: s.title,
202            state_name: s.state_name,
203            state_type: s.state_type,
204            priority: s.priority,
205            project_name: s.project_name,
206            labels: s.labels,
207            updated_at: s.updated_at,
208            url: s.url,
209            has_description: s.has_description,
210            has_embedding: s.has_embedding,
211        }
212    }
213}
214
215#[derive(uniffi::Record)]
216pub struct RtTeamSummary {
217    pub key: String,
218    pub issue_count: u64,
219    pub embedded_count: u64,
220    pub last_synced_at: Option<String>,
221}
222
223#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
224pub enum RtSyncPhase {
225    FetchingIssues,
226    GeneratingEmbeddings,
227}
228
229#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
230pub struct RtSyncProgress {
231    pub phase: RtSyncPhase,
232    pub completed: u64,
233    pub total: Option<u64>,
234}
235
236impl From<crate::db::TeamSummary> for RtTeamSummary {
237    fn from(t: crate::db::TeamSummary) -> Self {
238        Self {
239            key: t.key,
240            issue_count: t.issue_count as u64,
241            embedded_count: t.embedded_count as u64,
242            last_synced_at: t.last_synced_at,
243        }
244    }
245}
246
247impl From<RtSearchMode> for search::SearchMode {
248    fn from(mode: RtSearchMode) -> Self {
249        match mode {
250            RtSearchMode::Fts => search::SearchMode::Fts,
251            RtSearchMode::Vector => search::SearchMode::Vector,
252            RtSearchMode::Hybrid => search::SearchMode::Hybrid,
253        }
254    }
255}
256
257// ── Engine ───────────────────────────────────────────────────────────
258
259#[derive(uniffi::Object)]
260pub struct RectilinearEngine {
261    db: Database,
262    linear_api_key: String,
263    gemini_api_key: Option<String>,
264    sync_progress: Mutex<Option<RtSyncProgress>>,
265    /// Lazily initialized on first async call so it's created inside
266    /// UniFFI's Tokio runtime, binding hyper's DNS resolver to a live reactor.
267    http_client: OnceCell<reqwest::Client>,
268}
269
270impl RectilinearEngine {
271    /// Get or create the HTTP client. Lazily initialized so it's created
272    /// inside the caller's Tokio runtime (UniFFI's), binding hyper's DNS
273    /// resolver to a live reactor.
274    async fn client(&self) -> &reqwest::Client {
275        self.http_client
276            .get_or_init(|| async { reqwest::Client::new() })
277            .await
278    }
279}
280
281#[uniffi::export(async_runtime = "tokio")]
282impl RectilinearEngine {
283    /// Create a new engine with an explicit database path and API keys.
284    #[uniffi::constructor]
285    pub fn new(
286        db_path: String,
287        linear_api_key: String,
288        gemini_api_key: Option<String>,
289    ) -> Result<Self, RectilinearError> {
290        let path = Path::new(&db_path);
291        if let Some(parent) = path.parent() {
292            std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
293                message: format!("Failed to create database directory: {e}"),
294            })?;
295        }
296
297        let db = Database::open(path)?;
298
299        Ok(Self {
300            db,
301            linear_api_key,
302            gemini_api_key,
303            sync_progress: Mutex::new(None),
304            http_client: OnceCell::new(),
305        })
306    }
307
308    // ── Sync methods (database reads, fast) ──────────────────────
309
310    /// Look up an issue by UUID or identifier (e.g. "CUT-123").
311    pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
312        Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
313    }
314
315    /// Get unprioritized issues for triage.
316    pub fn get_triage_queue(
317        &self,
318        team: Option<String>,
319        include_completed: bool,
320    ) -> Result<Vec<RtIssue>, RectilinearError> {
321        let issues =
322            self.db
323                .get_unprioritized_issues(team.as_deref(), include_completed, "default")?;
324        Ok(issues.into_iter().map(RtIssue::from).collect())
325    }
326
327    /// Full-text search (FTS5, BM25 ranking). Synchronous — hits local SQLite only.
328    pub fn search_fts(
329        &self,
330        query: String,
331        limit: u32,
332    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
333        let results = self.db.fts_search(&query, limit as usize, "default")?;
334        Ok(results
335            .into_iter()
336            .map(|fts| RtSearchResult {
337                issue_id: fts.issue_id,
338                identifier: fts.identifier,
339                title: fts.title,
340                state_name: fts.state_name,
341                priority: fts.priority,
342                score: fts.bm25_score,
343                similarity: None,
344            })
345            .collect())
346    }
347
348    /// Count issues in the local database.
349    pub fn count_issues(&self, team: Option<String>) -> Result<u64, RectilinearError> {
350        Ok(self.db.count_issues(team.as_deref(), "default")? as u64)
351    }
352
353    /// Count issues that have at least one embedding chunk.
354    pub fn count_embedded_issues(&self, team: Option<String>) -> Result<u64, RectilinearError> {
355        Ok(self.db.count_embedded_issues(team.as_deref(), "default")? as u64)
356    }
357
358    /// Return the current sync progress, if a sync or embedding pass is active.
359    pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
360        self.sync_progress.lock().unwrap().clone()
361    }
362
363    /// Get field completeness counts in a single query.
364    pub fn get_field_completeness(
365        &self,
366        team: Option<String>,
367    ) -> Result<RtFieldCompleteness, RectilinearError> {
368        let (total, desc, pri, labels, proj) =
369            self.db.get_field_completeness(team.as_deref(), "default")?;
370        Ok(RtFieldCompleteness {
371            total: total as u64,
372            with_description: desc as u64,
373            with_priority: pri as u64,
374            with_labels: labels as u64,
375            with_project: proj as u64,
376        })
377    }
378
379    /// List all issues with lightweight summary data. Supports pagination and filtering.
380    pub fn list_all_issues(
381        &self,
382        team: Option<String>,
383        filter: Option<String>,
384        limit: u32,
385        offset: u32,
386    ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
387        let issues = self.db.list_all_issues(
388            team.as_deref(),
389            filter.as_deref(),
390            limit as usize,
391            offset as usize,
392            "default",
393        )?;
394        Ok(issues.into_iter().map(RtIssueSummary::from).collect())
395    }
396
397    /// List teams with synced issues and their embedding coverage. Local-only, no network.
398    pub fn list_synced_teams(&self) -> Result<Vec<RtTeamSummary>, RectilinearError> {
399        Ok(self
400            .db
401            .list_synced_teams("default")?
402            .into_iter()
403            .map(RtTeamSummary::from)
404            .collect())
405    }
406
407    /// Get enriched relations for an issue.
408    pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
409        Ok(self
410            .db
411            .get_relations_enriched(&issue_id)?
412            .into_iter()
413            .map(RtRelation::from)
414            .collect())
415    }
416
417    /// Get issues filtered by team and state types, enriched with blocker info.
418    pub fn get_active_issues(
419        &self,
420        team: String,
421        state_types: Vec<String>,
422    ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
423        let issues = self
424            .db
425            .get_issues_by_state_types(&team, &state_types, "default")?;
426        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
427        let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
428
429        // Group blockers by issue ID
430        let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
431            std::collections::HashMap::new();
432        for b in blockers {
433            let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
434            blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
435                identifier: b.identifier,
436                title: b.title,
437                state_name: b.state_name,
438                is_terminal,
439            });
440        }
441
442        Ok(issues
443            .into_iter()
444            .map(|issue| {
445                let labels: Vec<String> =
446                    serde_json::from_str(&issue.labels_json).unwrap_or_default();
447                let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
448                RtIssueEnriched {
449                    id: issue.id,
450                    identifier: issue.identifier,
451                    team_key: issue.team_key,
452                    title: issue.title,
453                    description: issue.description,
454                    state_name: issue.state_name,
455                    state_type: issue.state_type,
456                    priority: issue.priority,
457                    assignee_name: issue.assignee_name,
458                    project_name: issue.project_name,
459                    labels,
460                    created_at: issue.created_at,
461                    updated_at: issue.updated_at,
462                    url: issue.url,
463                    branch_name: issue.branch_name,
464                    blocked_by,
465                }
466            })
467            .collect())
468    }
469
470    // ── Async methods (network I/O) ─────────────────────────────
471
472    /// List all teams from Linear.
473    pub async fn list_teams(&self) -> Result<Vec<RtTeam>, RectilinearError> {
474        let client =
475            LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
476        let teams = client
477            .list_teams()
478            .await
479            .map_err(|e| RectilinearError::Api {
480                message: e.to_string(),
481            })?;
482        Ok(teams
483            .into_iter()
484            .map(|t| RtTeam {
485                id: t.id,
486                key: t.key,
487                name: t.name,
488            })
489            .collect())
490    }
491
492    /// Validate the configured Gemini API key without generating embeddings.
493    pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
494        let api_key = self
495            .gemini_api_key
496            .as_deref()
497            .ok_or_else(|| RectilinearError::Config {
498                message: "Gemini API key not configured".into(),
499            })?;
500
501        crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
502            .map_err(|e| RectilinearError::Config {
503                message: e.to_string(),
504            })?
505            .test_api_key()
506            .await
507            .map_err(|e| RectilinearError::Api {
508                message: e.to_string(),
509            })
510    }
511
512    /// Sync issues from Linear for a team. Returns the number of issues synced.
513    pub async fn sync_team(&self, team_key: String, full: bool) -> Result<u64, RectilinearError> {
514        self.set_sync_progress(Some(RtSyncProgress {
515            phase: RtSyncPhase::FetchingIssues,
516            completed: 0,
517            total: None,
518        }));
519
520        let client =
521            LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
522        let progress_state = &self.sync_progress;
523        let progress = move |count: usize| {
524            *progress_state.lock().unwrap() = Some(RtSyncProgress {
525                phase: RtSyncPhase::FetchingIssues,
526                completed: count as u64,
527                total: None,
528            });
529        };
530        let result = client
531            .sync_team(&self.db, &team_key, "default", full, false, Some(&progress))
532            .await
533            .map_err(|e| RectilinearError::Api {
534                message: e.to_string(),
535            });
536        self.set_sync_progress(None);
537        result.map(|count| count as u64)
538    }
539
540    /// Hybrid search (FTS + vector via RRF). Requires embedder for vector component.
541    pub async fn search_hybrid(
542        &self,
543        query: String,
544        team: Option<String>,
545        limit: u32,
546    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
547        let config = Config::load().unwrap_or_default();
548        let embedder = self.make_embedder(&config).await?;
549
550        let results = search::search(
551            &self.db,
552            &query,
553            search::SearchMode::Hybrid,
554            team.as_deref(),
555            None,
556            limit as usize,
557            embedder.as_ref(),
558            config.search.rrf_k,
559            "default",
560        )
561        .await?;
562
563        Ok(results.into_iter().map(RtSearchResult::from).collect())
564    }
565
566    /// Find potential duplicate issues by semantic similarity.
567    pub async fn find_duplicates(
568        &self,
569        text: String,
570        team: Option<String>,
571        threshold: f32,
572    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
573        let config = Config::load().unwrap_or_default();
574        let embedder =
575            self.make_embedder(&config)
576                .await?
577                .ok_or_else(|| RectilinearError::Config {
578                    message:
579                        "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
580                            .into(),
581                })?;
582
583        let results = search::find_duplicates(
584            &self.db,
585            &text,
586            team.as_deref(),
587            threshold,
588            10,
589            &embedder,
590            config.search.rrf_k,
591            "default",
592        )
593        .await?;
594
595        Ok(results.into_iter().map(RtSearchResult::from).collect())
596    }
597
598    /// Update an issue in Linear (title, description, priority, state, labels).
599    pub async fn save_issue(
600        &self,
601        issue_id: String,
602        title: Option<String>,
603        description: Option<String>,
604        priority: Option<i32>,
605        state: Option<String>,
606        labels: Option<Vec<String>>,
607    ) -> Result<(), RectilinearError> {
608        let client =
609            LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
610
611        let state_id = if let Some(ref state_name) = state {
612            // Need to resolve state name → ID. Get team from issue first.
613            if let Some(issue) = self.db.get_issue(&issue_id)? {
614                Some(
615                    client
616                        .get_state_id(&issue.team_key, state_name)
617                        .await
618                        .map_err(|e| RectilinearError::Api {
619                            message: e.to_string(),
620                        })?,
621                )
622            } else {
623                None
624            }
625        } else {
626            None
627        };
628
629        let label_ids =
630            if let Some(ref label_names) = labels {
631                Some(client.get_label_ids(label_names).await.map_err(|e| {
632                    RectilinearError::Api {
633                        message: e.to_string(),
634                    }
635                })?)
636            } else {
637                None
638            };
639
640        client
641            .update_issue(
642                &issue_id,
643                title.as_deref(),
644                description.as_deref(),
645                priority,
646                state_id.as_deref(),
647                label_ids.as_deref(),
648                None,
649            )
650            .await
651            .map_err(|e| RectilinearError::Api {
652                message: e.to_string(),
653            })?;
654
655        // Re-sync the updated issue back to local DB
656        if let Ok((issue, relations)) = client.fetch_single_issue(&issue_id).await {
657            let _ = self.db.upsert_issue(&issue);
658            let _ = self.db.upsert_relations(&issue.id, &relations);
659        }
660
661        Ok(())
662    }
663
664    /// Add a comment to a Linear issue.
665    pub async fn add_comment(
666        &self,
667        issue_id: String,
668        body: String,
669    ) -> Result<(), RectilinearError> {
670        let client =
671            LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
672        client
673            .add_comment(&issue_id, &body)
674            .await
675            .map_err(|e| RectilinearError::Api {
676                message: e.to_string(),
677            })
678    }
679
680    /// Fetch a single issue live from Linear and upsert into local DB.
681    /// Accepts either a UUID or identifier (e.g. "CUT-123").
682    pub async fn refresh_issue(
683        &self,
684        id_or_identifier: String,
685    ) -> Result<Option<RtIssue>, RectilinearError> {
686        let client =
687            LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
688
689        let result = if id_or_identifier.contains('-')
690            && id_or_identifier
691                .chars()
692                .last()
693                .is_some_and(|c| c.is_ascii_digit())
694        {
695            client
696                .fetch_issue_by_identifier(&id_or_identifier)
697                .await
698                .map_err(|e| RectilinearError::Api {
699                    message: e.to_string(),
700                })?
701        } else {
702            Some(
703                client
704                    .fetch_single_issue(&id_or_identifier)
705                    .await
706                    .map_err(|e| RectilinearError::Api {
707                        message: e.to_string(),
708                    })?,
709            )
710        };
711
712        if let Some((issue, relations)) = result {
713            self.db.upsert_issue(&issue)?;
714            self.db.upsert_relations(&issue.id, &relations)?;
715            Ok(Some(RtIssue::from(issue)))
716        } else {
717            Ok(None)
718        }
719    }
720
721    /// Generate embeddings for issues that don't have them yet.
722    /// Returns the number of issues embedded.
723    pub async fn embed_issues(
724        &self,
725        team: Option<String>,
726        limit: u32,
727    ) -> Result<u64, RectilinearError> {
728        let config = Config::load().unwrap_or_default();
729        let embedder =
730            self.make_embedder(&config)
731                .await?
732                .ok_or_else(|| {
733                    RectilinearError::Config {
734                message:
735                    "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
736                        .into(),
737            }
738                })?;
739
740        let model_name = embedder.backend_name().to_string();
741        let issues = self
742            .db
743            .get_issues_needing_embedding(team.as_deref(), false, "default")?;
744
745        let to_process = if limit > 0 {
746            &issues[..std::cmp::min(issues.len(), limit as usize)]
747        } else {
748            &issues
749        };
750        let total = to_process.len() as u64;
751
752        self.set_sync_progress(Some(RtSyncProgress {
753            phase: RtSyncPhase::GeneratingEmbeddings,
754            completed: 0,
755            total: Some(total),
756        }));
757
758        let result: Result<u64, RectilinearError> = async {
759            let mut count = 0u64;
760            for issue in to_process {
761                // Skip if already embedded with the same model and content hasn't changed
762                if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
763                    if existing_model == model_name {
764                        continue;
765                    }
766                }
767
768                let chunks = crate::embedding::chunk_text(
769                    &issue.title,
770                    issue.description.as_deref().unwrap_or(""),
771                    512,
772                    64,
773                );
774                let embeddings =
775                    embedder
776                        .embed_batch(&chunks)
777                        .await
778                        .map_err(|e| RectilinearError::Api {
779                            message: e.to_string(),
780                        })?;
781
782                let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
783                    .into_iter()
784                    .zip(embeddings.iter())
785                    .enumerate()
786                    .map(|(idx, (text, emb))| {
787                        (idx, text, crate::embedding::embedding_to_bytes(emb))
788                    })
789                    .collect();
790
791                self.db
792                    .upsert_chunks_with_model(&issue.id, &chunk_data, &model_name)?;
793                count += 1;
794                self.set_sync_progress(Some(RtSyncProgress {
795                    phase: RtSyncPhase::GeneratingEmbeddings,
796                    completed: count,
797                    total: Some(total),
798                }));
799            }
800
801            Ok(count)
802        }
803        .await;
804
805        self.set_sync_progress(None);
806        result
807    }
808}
809
810// ── Private helpers ──────────────────────────────────────────────────
811
812impl RectilinearEngine {
813    fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
814        *self.sync_progress.lock().unwrap() = progress;
815    }
816
817    async fn make_embedder(
818        &self,
819        config: &Config,
820    ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
821        let key = self
822            .gemini_api_key
823            .as_deref()
824            .or(config.embedding.gemini_api_key.as_deref());
825
826        if let Some(api_key) = key {
827            Ok(Some(
828                crate::embedding::Embedder::new_api_with_http_client(
829                    self.client().await.clone(),
830                    api_key,
831                )
832                .map_err(|e| RectilinearError::Config {
833                    message: e.to_string(),
834                })?,
835            ))
836        } else {
837            #[cfg(feature = "local-embeddings")]
838            {
839                let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
840                    message: e.to_string(),
841                })?;
842                Ok(Some(
843                    crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
844                        RectilinearError::Config {
845                            message: e.to_string(),
846                        }
847                    })?,
848                ))
849            }
850            #[cfg(not(feature = "local-embeddings"))]
851            {
852                Ok(None)
853            }
854        }
855    }
856}