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    gemini_api_key: Option<String>,
263    sync_progress: Mutex<Option<RtSyncProgress>>,
264    /// Lazily initialized on first async call so it's created inside
265    /// UniFFI's Tokio runtime, binding hyper's DNS resolver to a live reactor.
266    http_client: OnceCell<reqwest::Client>,
267}
268
269impl RectilinearEngine {
270    /// Get or create the HTTP client. Lazily initialized so it's created
271    /// inside the caller's Tokio runtime (UniFFI's), binding hyper's DNS
272    /// resolver to a live reactor.
273    async fn client(&self) -> &reqwest::Client {
274        self.http_client
275            .get_or_init(|| async { reqwest::Client::new() })
276            .await
277    }
278}
279
280#[uniffi::export(async_runtime = "tokio")]
281impl RectilinearEngine {
282    /// Create a new engine with an explicit database path and optional Gemini API key.
283    /// Linear API keys are resolved per-workspace from config.
284    #[uniffi::constructor]
285    pub fn new(
286        db_path: String,
287        gemini_api_key: Option<String>,
288    ) -> Result<Self, RectilinearError> {
289        let path = Path::new(&db_path);
290        if let Some(parent) = path.parent() {
291            std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
292                message: format!("Failed to create database directory: {e}"),
293            })?;
294        }
295
296        let db = Database::open(path)?;
297
298        Ok(Self {
299            db,
300            gemini_api_key,
301            sync_progress: Mutex::new(None),
302            http_client: OnceCell::new(),
303        })
304    }
305
306    /// Resolve the Linear API key for a given workspace from config.
307    pub fn linear_api_key_for_workspace(
308        &self,
309        workspace_id: &str,
310    ) -> Result<String, RectilinearError> {
311        let config = Config::load().map_err(|e| RectilinearError::Config {
312            message: e.to_string(),
313        })?;
314        config
315            .workspace_api_key(workspace_id)
316            .map_err(|e| RectilinearError::Config {
317                message: e.to_string(),
318            })
319    }
320
321    /// List all configured workspace names.
322    pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
323        let config = Config::load().map_err(|e| RectilinearError::Config {
324            message: e.to_string(),
325        })?;
326        Ok(config.workspace_names())
327    }
328
329    /// Get the active workspace name.
330    pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
331        let config = Config::load().map_err(|e| RectilinearError::Config {
332            message: e.to_string(),
333        })?;
334        config
335            .resolve_active_workspace()
336            .map_err(|e| RectilinearError::Config {
337                message: e.to_string(),
338            })
339    }
340
341    // ── Sync methods (database reads, fast) ──────────────────────
342
343    /// Look up an issue by UUID or identifier (e.g. "CUT-123").
344    pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
345        Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
346    }
347
348    /// Get unprioritized issues for triage.
349    pub fn get_triage_queue(
350        &self,
351        team: Option<String>,
352        include_completed: bool,
353        workspace_id: String,
354    ) -> Result<Vec<RtIssue>, RectilinearError> {
355        let issues =
356            self.db
357                .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
358        Ok(issues.into_iter().map(RtIssue::from).collect())
359    }
360
361    /// Full-text search (FTS5, BM25 ranking). Synchronous — hits local SQLite only.
362    pub fn search_fts(
363        &self,
364        query: String,
365        limit: u32,
366        workspace_id: String,
367    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
368        let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
369        Ok(results
370            .into_iter()
371            .map(|fts| RtSearchResult {
372                issue_id: fts.issue_id,
373                identifier: fts.identifier,
374                title: fts.title,
375                state_name: fts.state_name,
376                priority: fts.priority,
377                score: fts.bm25_score,
378                similarity: None,
379            })
380            .collect())
381    }
382
383    /// Count issues in the local database.
384    pub fn count_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
385        Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
386    }
387
388    /// Count issues that have at least one embedding chunk.
389    pub fn count_embedded_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
390        Ok(self.db.count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
391    }
392
393    /// Return the current sync progress, if a sync or embedding pass is active.
394    pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
395        self.sync_progress.lock().unwrap().clone()
396    }
397
398    /// Get field completeness counts in a single query.
399    pub fn get_field_completeness(
400        &self,
401        team: Option<String>,
402        workspace_id: String,
403    ) -> Result<RtFieldCompleteness, RectilinearError> {
404        let (total, desc, pri, labels, proj) =
405            self.db.get_field_completeness(team.as_deref(), &workspace_id)?;
406        Ok(RtFieldCompleteness {
407            total: total as u64,
408            with_description: desc as u64,
409            with_priority: pri as u64,
410            with_labels: labels as u64,
411            with_project: proj as u64,
412        })
413    }
414
415    /// List all issues with lightweight summary data. Supports pagination and filtering.
416    pub fn list_all_issues(
417        &self,
418        team: Option<String>,
419        filter: Option<String>,
420        limit: u32,
421        offset: u32,
422        workspace_id: String,
423    ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
424        let issues = self.db.list_all_issues(
425            team.as_deref(),
426            filter.as_deref(),
427            limit as usize,
428            offset as usize,
429            &workspace_id,
430        )?;
431        Ok(issues.into_iter().map(RtIssueSummary::from).collect())
432    }
433
434    /// List teams with synced issues and their embedding coverage. Local-only, no network.
435    pub fn list_synced_teams(&self, workspace_id: String) -> Result<Vec<RtTeamSummary>, RectilinearError> {
436        Ok(self
437            .db
438            .list_synced_teams(&workspace_id)?
439            .into_iter()
440            .map(RtTeamSummary::from)
441            .collect())
442    }
443
444    /// Get enriched relations for an issue.
445    pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
446        Ok(self
447            .db
448            .get_relations_enriched(&issue_id)?
449            .into_iter()
450            .map(RtRelation::from)
451            .collect())
452    }
453
454    /// Get issues filtered by team and state types, enriched with blocker info.
455    pub fn get_active_issues(
456        &self,
457        team: String,
458        state_types: Vec<String>,
459        workspace_id: String,
460    ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
461        let issues = self
462            .db
463            .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
464        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
465        let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
466
467        // Group blockers by issue ID
468        let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
469            std::collections::HashMap::new();
470        for b in blockers {
471            let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
472            blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
473                identifier: b.identifier,
474                title: b.title,
475                state_name: b.state_name,
476                is_terminal,
477            });
478        }
479
480        Ok(issues
481            .into_iter()
482            .map(|issue| {
483                let labels: Vec<String> =
484                    serde_json::from_str(&issue.labels_json).unwrap_or_default();
485                let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
486                RtIssueEnriched {
487                    id: issue.id,
488                    identifier: issue.identifier,
489                    team_key: issue.team_key,
490                    title: issue.title,
491                    description: issue.description,
492                    state_name: issue.state_name,
493                    state_type: issue.state_type,
494                    priority: issue.priority,
495                    assignee_name: issue.assignee_name,
496                    project_name: issue.project_name,
497                    labels,
498                    created_at: issue.created_at,
499                    updated_at: issue.updated_at,
500                    url: issue.url,
501                    branch_name: issue.branch_name,
502                    blocked_by,
503                }
504            })
505            .collect())
506    }
507
508    // ── Async methods (network I/O) ─────────────────────────────
509
510    /// List all teams from Linear.
511    pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
512        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
513        let client =
514            LinearClient::with_http_client(self.client().await.clone(), &api_key);
515        let teams = client
516            .list_teams()
517            .await
518            .map_err(|e| RectilinearError::Api {
519                message: e.to_string(),
520            })?;
521        Ok(teams
522            .into_iter()
523            .map(|t| RtTeam {
524                id: t.id,
525                key: t.key,
526                name: t.name,
527            })
528            .collect())
529    }
530
531    /// Validate the configured Gemini API key without generating embeddings.
532    pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
533        let api_key = self
534            .gemini_api_key
535            .as_deref()
536            .ok_or_else(|| RectilinearError::Config {
537                message: "Gemini API key not configured".into(),
538            })?;
539
540        crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
541            .map_err(|e| RectilinearError::Config {
542                message: e.to_string(),
543            })?
544            .test_api_key()
545            .await
546            .map_err(|e| RectilinearError::Api {
547                message: e.to_string(),
548            })
549    }
550
551    /// Sync issues from Linear for a team. Returns the number of issues synced.
552    pub async fn sync_team(&self, team_key: String, full: bool, workspace_id: String) -> Result<u64, RectilinearError> {
553        self.set_sync_progress(Some(RtSyncProgress {
554            phase: RtSyncPhase::FetchingIssues,
555            completed: 0,
556            total: None,
557        }));
558
559        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
560        let client =
561            LinearClient::with_http_client(self.client().await.clone(), &api_key);
562        let progress_state = &self.sync_progress;
563        let progress = move |count: usize| {
564            *progress_state.lock().unwrap() = Some(RtSyncProgress {
565                phase: RtSyncPhase::FetchingIssues,
566                completed: count as u64,
567                total: None,
568            });
569        };
570        let result = client
571            .sync_team(&self.db, &team_key, &workspace_id, full, false, Some(&progress))
572            .await
573            .map_err(|e| RectilinearError::Api {
574                message: e.to_string(),
575            });
576        self.set_sync_progress(None);
577        result.map(|count| count as u64)
578    }
579
580    /// Hybrid search (FTS + vector via RRF). Requires embedder for vector component.
581    pub async fn search_hybrid(
582        &self,
583        query: String,
584        team: Option<String>,
585        limit: u32,
586        workspace_id: String,
587    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
588        let config = Config::load().unwrap_or_default();
589        let embedder = self.make_embedder(&config).await?;
590
591        let results = search::search(
592            &self.db,
593            search::SearchParams {
594                query: &query,
595                mode: search::SearchMode::Hybrid,
596                team_key: team.as_deref(),
597                state_filter: None,
598                limit: limit as usize,
599                embedder: embedder.as_ref(),
600                rrf_k: config.search.rrf_k,
601                workspace_id: &workspace_id,
602            },
603        )
604        .await?;
605
606        Ok(results.into_iter().map(RtSearchResult::from).collect())
607    }
608
609    /// Find potential duplicate issues by semantic similarity.
610    pub async fn find_duplicates(
611        &self,
612        text: String,
613        team: Option<String>,
614        threshold: f32,
615        workspace_id: String,
616    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
617        let config = Config::load().unwrap_or_default();
618        let embedder =
619            self.make_embedder(&config)
620                .await?
621                .ok_or_else(|| RectilinearError::Config {
622                    message:
623                        "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
624                            .into(),
625                })?;
626
627        let results = search::find_duplicates(
628            &self.db,
629            &text,
630            team.as_deref(),
631            threshold,
632            10,
633            &embedder,
634            config.search.rrf_k,
635            &workspace_id,
636        )
637        .await?;
638
639        Ok(results.into_iter().map(RtSearchResult::from).collect())
640    }
641
642    /// Update an issue in Linear (title, description, priority, state, labels).
643    pub async fn save_issue(
644        &self,
645        issue_id: String,
646        title: Option<String>,
647        description: Option<String>,
648        priority: Option<i32>,
649        state: Option<String>,
650        labels: Option<Vec<String>>,
651        workspace_id: String,
652    ) -> Result<(), RectilinearError> {
653        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
654        let client =
655            LinearClient::with_http_client(self.client().await.clone(), &api_key);
656
657        let state_id = if let Some(ref state_name) = state {
658            // Need to resolve state name → ID. Get team from issue first.
659            if let Some(issue) = self.db.get_issue(&issue_id)? {
660                Some(
661                    client
662                        .get_state_id(&issue.team_key, state_name)
663                        .await
664                        .map_err(|e| RectilinearError::Api {
665                            message: e.to_string(),
666                        })?,
667                )
668            } else {
669                None
670            }
671        } else {
672            None
673        };
674
675        let label_ids =
676            if let Some(ref label_names) = labels {
677                Some(client.get_label_ids(label_names).await.map_err(|e| {
678                    RectilinearError::Api {
679                        message: e.to_string(),
680                    }
681                })?)
682            } else {
683                None
684            };
685
686        client
687            .update_issue(
688                &issue_id,
689                title.as_deref(),
690                description.as_deref(),
691                priority,
692                state_id.as_deref(),
693                label_ids.as_deref(),
694                None,
695            )
696            .await
697            .map_err(|e| RectilinearError::Api {
698                message: e.to_string(),
699            })?;
700
701        // Re-sync the updated issue back to local DB
702        if let Ok((issue, relations)) = client.fetch_single_issue(&issue_id).await {
703            let _ = self.db.upsert_issue(&issue);
704            let _ = self.db.upsert_relations(&issue.id, &relations);
705        }
706
707        Ok(())
708    }
709
710    /// Add a comment to a Linear issue.
711    pub async fn add_comment(
712        &self,
713        issue_id: String,
714        body: String,
715        workspace_id: String,
716    ) -> Result<(), RectilinearError> {
717        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
718        let client =
719            LinearClient::with_http_client(self.client().await.clone(), &api_key);
720        client
721            .add_comment(&issue_id, &body)
722            .await
723            .map_err(|e| RectilinearError::Api {
724                message: e.to_string(),
725            })
726    }
727
728    /// Fetch a single issue live from Linear and upsert into local DB.
729    /// Accepts either a UUID or identifier (e.g. "CUT-123").
730    pub async fn refresh_issue(
731        &self,
732        id_or_identifier: String,
733        workspace_id: String,
734    ) -> Result<Option<RtIssue>, RectilinearError> {
735        let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
736        let client =
737            LinearClient::with_http_client(self.client().await.clone(), &api_key);
738
739        let result = if id_or_identifier.contains('-')
740            && id_or_identifier
741                .chars()
742                .last()
743                .is_some_and(|c| c.is_ascii_digit())
744        {
745            client
746                .fetch_issue_by_identifier(&id_or_identifier)
747                .await
748                .map_err(|e| RectilinearError::Api {
749                    message: e.to_string(),
750                })?
751        } else {
752            Some(
753                client
754                    .fetch_single_issue(&id_or_identifier)
755                    .await
756                    .map_err(|e| RectilinearError::Api {
757                        message: e.to_string(),
758                    })?,
759            )
760        };
761
762        if let Some((issue, relations)) = result {
763            self.db.upsert_issue(&issue)?;
764            self.db.upsert_relations(&issue.id, &relations)?;
765            Ok(Some(RtIssue::from(issue)))
766        } else {
767            Ok(None)
768        }
769    }
770
771    /// Generate embeddings for issues that don't have them yet.
772    /// Returns the number of issues embedded.
773    pub async fn embed_issues(
774        &self,
775        team: Option<String>,
776        limit: u32,
777        workspace_id: String,
778    ) -> Result<u64, RectilinearError> {
779        let config = Config::load().unwrap_or_default();
780        let embedder =
781            self.make_embedder(&config)
782                .await?
783                .ok_or_else(|| {
784                    RectilinearError::Config {
785                message:
786                    "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
787                        .into(),
788            }
789                })?;
790
791        let model_name = embedder.backend_name().to_string();
792        let issues = self
793            .db
794            .get_issues_needing_embedding(team.as_deref(), false, &workspace_id)?;
795
796        let to_process = if limit > 0 {
797            &issues[..std::cmp::min(issues.len(), limit as usize)]
798        } else {
799            &issues
800        };
801        let total = to_process.len() as u64;
802
803        self.set_sync_progress(Some(RtSyncProgress {
804            phase: RtSyncPhase::GeneratingEmbeddings,
805            completed: 0,
806            total: Some(total),
807        }));
808
809        // Collect chunks from multiple issues into batches to reduce API round-trips.
810        // Each Gemini batchEmbedContents call handles up to 100 texts, so we fill
811        // batches across issue boundaries rather than making one call per issue.
812        const BATCH_SIZE: usize = 100;
813
814        // Pre-chunk all issues, skipping those already embedded with the current model.
815        struct IssueChunks {
816            issue_id: String,
817            chunks: Vec<String>,
818        }
819        let mut pending: Vec<IssueChunks> = Vec::new();
820        for issue in to_process {
821            if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
822                if existing_model == model_name {
823                    continue;
824                }
825            }
826            let chunks = crate::embedding::chunk_text(
827                &issue.title,
828                issue.description.as_deref().unwrap_or(""),
829                512,
830                64,
831            );
832            pending.push(IssueChunks {
833                issue_id: issue.id.clone(),
834                chunks,
835            });
836        }
837
838        let result: Result<u64, RectilinearError> = async {
839            // Flatten all chunks into a single list with back-references to their issue.
840            // Each entry: (index into `pending`, chunk_index_within_issue, chunk_text)
841            let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
842            for (issue_idx, ic) in pending.iter().enumerate() {
843                for (chunk_idx, text) in ic.chunks.iter().enumerate() {
844                    flat_chunks.push((issue_idx, chunk_idx, text.clone()));
845                }
846            }
847
848            // Embed in batches of BATCH_SIZE across issue boundaries.
849            let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
850            for batch in flat_chunks.chunks(BATCH_SIZE) {
851                let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
852                let batch_embeddings =
853                    embedder
854                        .embed_batch(&texts)
855                        .await
856                        .map_err(|e| RectilinearError::Api {
857                            message: e.to_string(),
858                        })?;
859                embeddings_flat.extend(batch_embeddings);
860            }
861
862            // Re-group embeddings back to their issues and persist.
863            let mut emb_offset = 0usize;
864            let mut count = 0u64;
865            for ic in &pending {
866                let n = ic.chunks.len();
867                let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
868
869                let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
870                    .chunks
871                    .iter()
872                    .zip(issue_embeddings.iter())
873                    .enumerate()
874                    .map(|(idx, (text, emb))| {
875                        (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
876                    })
877                    .collect();
878
879                self.db
880                    .upsert_chunks_with_model(&ic.issue_id, &chunk_data, &model_name)?;
881                emb_offset += n;
882                count += 1;
883                self.set_sync_progress(Some(RtSyncProgress {
884                    phase: RtSyncPhase::GeneratingEmbeddings,
885                    completed: count,
886                    total: Some(total),
887                }));
888            }
889
890            Ok(count)
891        }
892        .await;
893
894        self.set_sync_progress(None);
895        result
896    }
897}
898
899// ── Private helpers ──────────────────────────────────────────────────
900
901impl RectilinearEngine {
902    fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
903        *self.sync_progress.lock().unwrap() = progress;
904    }
905
906    async fn make_embedder(
907        &self,
908        config: &Config,
909    ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
910        let key = self
911            .gemini_api_key
912            .as_deref()
913            .or(config.embedding.gemini_api_key.as_deref());
914
915        if let Some(api_key) = key {
916            Ok(Some(
917                crate::embedding::Embedder::new_api_with_http_client(
918                    self.client().await.clone(),
919                    api_key,
920                )
921                .map_err(|e| RectilinearError::Config {
922                    message: e.to_string(),
923                })?,
924            ))
925        } else {
926            #[cfg(feature = "local-embeddings")]
927            {
928                let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
929                    message: e.to_string(),
930                })?;
931                Ok(Some(
932                    crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
933                        RectilinearError::Config {
934                            message: e.to_string(),
935                        }
936                    })?,
937                ))
938            }
939            #[cfg(not(feature = "local-embeddings"))]
940            {
941                Ok(None)
942            }
943        }
944    }
945}