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