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::Arc;
13
14// ── Error ────────────────────────────────────────────────────────────
15
16#[derive(Debug, thiserror::Error, uniffi::Error)]
17pub enum RectilinearError {
18    #[error("Database error: {message}")]
19    Database { message: String },
20    #[error("API error: {message}")]
21    Api { message: String },
22    #[error("Config error: {message}")]
23    Config { message: String },
24    #[error("Not found: {key}")]
25    NotFound { key: String },
26}
27
28impl From<anyhow::Error> for RectilinearError {
29    fn from(err: anyhow::Error) -> Self {
30        RectilinearError::Database {
31            message: err.to_string(),
32        }
33    }
34}
35
36// ── FFI Records ──────────────────────────────────────────────────────
37
38#[derive(uniffi::Record)]
39pub struct RtIssue {
40    pub id: String,
41    pub identifier: String,
42    pub team_key: String,
43    pub title: String,
44    pub description: Option<String>,
45    pub state_name: String,
46    pub state_type: String,
47    pub priority: i32,
48    pub assignee_name: Option<String>,
49    pub project_name: Option<String>,
50    pub labels: Vec<String>,
51    pub created_at: String,
52    pub updated_at: String,
53    pub url: String,
54}
55
56impl From<crate::db::Issue> for RtIssue {
57    fn from(issue: crate::db::Issue) -> Self {
58        let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
59        Self {
60            id: issue.id,
61            identifier: issue.identifier,
62            team_key: issue.team_key,
63            title: issue.title,
64            description: issue.description,
65            state_name: issue.state_name,
66            state_type: issue.state_type,
67            priority: issue.priority,
68            assignee_name: issue.assignee_name,
69            project_name: issue.project_name,
70            labels,
71            created_at: issue.created_at,
72            updated_at: issue.updated_at,
73            url: issue.url,
74        }
75    }
76}
77
78#[derive(uniffi::Record)]
79pub struct RtSearchResult {
80    pub issue_id: String,
81    pub identifier: String,
82    pub title: String,
83    pub state_name: String,
84    pub priority: i32,
85    pub score: f64,
86    pub similarity: Option<f32>,
87}
88
89impl From<search::SearchResult> for RtSearchResult {
90    fn from(sr: search::SearchResult) -> Self {
91        Self {
92            issue_id: sr.issue_id,
93            identifier: sr.identifier,
94            title: sr.title,
95            state_name: sr.state_name,
96            priority: sr.priority,
97            score: sr.score,
98            similarity: sr.similarity,
99        }
100    }
101}
102
103#[derive(uniffi::Record)]
104pub struct RtRelation {
105    pub relation_type: String,
106    pub issue_identifier: String,
107    pub issue_title: String,
108    pub issue_state: String,
109    pub issue_url: String,
110}
111
112impl From<crate::db::EnrichedRelation> for RtRelation {
113    fn from(rel: crate::db::EnrichedRelation) -> Self {
114        Self {
115            relation_type: rel.relation_type,
116            issue_identifier: rel.issue_identifier,
117            issue_title: rel.issue_title,
118            issue_state: rel.issue_state,
119            issue_url: rel.issue_url,
120        }
121    }
122}
123
124#[derive(uniffi::Record)]
125pub struct RtBlocker {
126    pub identifier: String,
127    pub title: String,
128    pub state_name: String,
129    pub is_terminal: bool,
130}
131
132#[derive(uniffi::Record)]
133pub struct RtIssueEnriched {
134    pub id: String,
135    pub identifier: String,
136    pub team_key: String,
137    pub title: String,
138    pub description: Option<String>,
139    pub state_name: String,
140    pub state_type: String,
141    pub priority: i32,
142    pub assignee_name: Option<String>,
143    pub project_name: Option<String>,
144    pub labels: Vec<String>,
145    pub created_at: String,
146    pub updated_at: String,
147    pub url: String,
148    pub blocked_by: Vec<RtBlocker>,
149}
150
151#[derive(uniffi::Enum)]
152pub enum RtSearchMode {
153    Fts,
154    Vector,
155    Hybrid,
156}
157
158impl From<RtSearchMode> for search::SearchMode {
159    fn from(mode: RtSearchMode) -> Self {
160        match mode {
161            RtSearchMode::Fts => search::SearchMode::Fts,
162            RtSearchMode::Vector => search::SearchMode::Vector,
163            RtSearchMode::Hybrid => search::SearchMode::Hybrid,
164        }
165    }
166}
167
168// ── Engine ───────────────────────────────────────────────────────────
169
170#[derive(uniffi::Object)]
171pub struct RectilinearEngine {
172    db: Database,
173    linear_api_key: String,
174    gemini_api_key: Option<String>,
175    /// Kept alive so async methods have a runtime. Not read directly.
176    _runtime: Arc<tokio::runtime::Runtime>,
177}
178
179#[uniffi::export]
180impl RectilinearEngine {
181    /// Create a new engine with an explicit database path and API keys.
182    #[uniffi::constructor]
183    pub fn new(
184        db_path: String,
185        linear_api_key: String,
186        gemini_api_key: Option<String>,
187    ) -> Result<Self, RectilinearError> {
188        let runtime = tokio::runtime::Runtime::new().map_err(|e| RectilinearError::Config {
189            message: format!("Failed to create async runtime: {e}"),
190        })?;
191
192        let path = Path::new(&db_path);
193        if let Some(parent) = path.parent() {
194            std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
195                message: format!("Failed to create database directory: {e}"),
196            })?;
197        }
198
199        let db = Database::open(path)?;
200
201        Ok(Self {
202            db,
203            linear_api_key,
204            gemini_api_key,
205            _runtime: Arc::new(runtime),
206        })
207    }
208
209    // ── Sync methods (database reads, fast) ──────────────────────
210
211    /// Look up an issue by UUID or identifier (e.g. "CUT-123").
212    pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
213        Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
214    }
215
216    /// Get unprioritized issues for triage.
217    pub fn get_triage_queue(
218        &self,
219        team: Option<String>,
220        include_completed: bool,
221    ) -> Result<Vec<RtIssue>, RectilinearError> {
222        let issues = self
223            .db
224            .get_unprioritized_issues(team.as_deref(), include_completed)?;
225        Ok(issues.into_iter().map(RtIssue::from).collect())
226    }
227
228    /// Full-text search (FTS5, BM25 ranking). Synchronous — hits local SQLite only.
229    pub fn search_fts(
230        &self,
231        query: String,
232        limit: u32,
233    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
234        let results = self.db.fts_search(&query, limit as usize)?;
235        Ok(results
236            .into_iter()
237            .map(|fts| RtSearchResult {
238                issue_id: fts.issue_id,
239                identifier: fts.identifier,
240                title: fts.title,
241                state_name: fts.state_name,
242                priority: fts.priority,
243                score: fts.bm25_score,
244                similarity: None,
245            })
246            .collect())
247    }
248
249    /// Count issues in the local database.
250    pub fn count_issues(&self, team: Option<String>) -> Result<u64, RectilinearError> {
251        Ok(self.db.count_issues(team.as_deref())? as u64)
252    }
253
254    /// Get enriched relations for an issue.
255    pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
256        Ok(self
257            .db
258            .get_relations_enriched(&issue_id)?
259            .into_iter()
260            .map(RtRelation::from)
261            .collect())
262    }
263
264    /// Get issues filtered by team and state types, enriched with blocker info.
265    pub fn get_active_issues(
266        &self,
267        team: String,
268        state_types: Vec<String>,
269    ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
270        let issues = self.db.get_issues_by_state_types(&team, &state_types)?;
271        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
272        let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
273
274        // Group blockers by issue ID
275        let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
276            std::collections::HashMap::new();
277        for b in blockers {
278            let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
279            blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
280                identifier: b.identifier,
281                title: b.title,
282                state_name: b.state_name,
283                is_terminal,
284            });
285        }
286
287        Ok(issues
288            .into_iter()
289            .map(|issue| {
290                let labels: Vec<String> =
291                    serde_json::from_str(&issue.labels_json).unwrap_or_default();
292                let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
293                RtIssueEnriched {
294                    id: issue.id,
295                    identifier: issue.identifier,
296                    team_key: issue.team_key,
297                    title: issue.title,
298                    description: issue.description,
299                    state_name: issue.state_name,
300                    state_type: issue.state_type,
301                    priority: issue.priority,
302                    assignee_name: issue.assignee_name,
303                    project_name: issue.project_name,
304                    labels,
305                    created_at: issue.created_at,
306                    updated_at: issue.updated_at,
307                    url: issue.url,
308                    blocked_by,
309                }
310            })
311            .collect())
312    }
313
314    // ── Async methods (network I/O) ─────────────────────────────
315
316    /// Sync issues from Linear for a team. Returns the number of issues synced.
317    pub async fn sync_team(&self, team_key: String, full: bool) -> Result<u64, RectilinearError> {
318        let client = LinearClient::with_api_key(&self.linear_api_key);
319        let count = client
320            .sync_team(&self.db, &team_key, full, false, None)
321            .await
322            .map_err(|e| RectilinearError::Api {
323                message: e.to_string(),
324            })?;
325        Ok(count as u64)
326    }
327
328    /// Hybrid search (FTS + vector via RRF). Requires embedder for vector component.
329    pub async fn search_hybrid(
330        &self,
331        query: String,
332        team: Option<String>,
333        limit: u32,
334    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
335        let config = Config::load().unwrap_or_default();
336        let embedder = self.make_embedder(&config).await?;
337
338        let results = search::search(
339            &self.db,
340            &query,
341            search::SearchMode::Hybrid,
342            team.as_deref(),
343            None,
344            limit as usize,
345            embedder.as_ref(),
346            config.search.rrf_k,
347        )
348        .await?;
349
350        Ok(results.into_iter().map(RtSearchResult::from).collect())
351    }
352
353    /// Find potential duplicate issues by semantic similarity.
354    pub async fn find_duplicates(
355        &self,
356        text: String,
357        team: Option<String>,
358        threshold: f32,
359    ) -> Result<Vec<RtSearchResult>, RectilinearError> {
360        let config = Config::load().unwrap_or_default();
361        let embedder =
362            self.make_embedder(&config)
363                .await?
364                .ok_or_else(|| RectilinearError::Config {
365                    message:
366                        "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
367                            .into(),
368                })?;
369
370        let results = search::find_duplicates(
371            &self.db,
372            &text,
373            team.as_deref(),
374            threshold,
375            10,
376            &embedder,
377            config.search.rrf_k,
378        )
379        .await?;
380
381        Ok(results.into_iter().map(RtSearchResult::from).collect())
382    }
383
384    /// Update an issue in Linear (title, priority, state).
385    pub async fn save_issue(
386        &self,
387        issue_id: String,
388        title: Option<String>,
389        priority: Option<i32>,
390        state: Option<String>,
391    ) -> Result<(), RectilinearError> {
392        let client = LinearClient::with_api_key(&self.linear_api_key);
393
394        let state_id = if let Some(ref state_name) = state {
395            // Need to resolve state name → ID. Get team from issue first.
396            if let Some(issue) = self.db.get_issue(&issue_id)? {
397                Some(
398                    client
399                        .get_state_id(&issue.team_key, state_name)
400                        .await
401                        .map_err(|e| RectilinearError::Api {
402                            message: e.to_string(),
403                        })?,
404                )
405            } else {
406                None
407            }
408        } else {
409            None
410        };
411
412        client
413            .update_issue(
414                &issue_id,
415                title.as_deref(),
416                None,
417                priority,
418                state_id.as_deref(),
419                None,
420                None,
421            )
422            .await
423            .map_err(|e| RectilinearError::Api {
424                message: e.to_string(),
425            })?;
426
427        // Re-sync the updated issue back to local DB
428        if let Ok((issue, relations)) = client.fetch_single_issue(&issue_id).await {
429            let _ = self.db.upsert_issue(&issue);
430            let _ = self.db.upsert_relations(&issue.id, &relations);
431        }
432
433        Ok(())
434    }
435}
436
437// ── Private helpers ──────────────────────────────────────────────────
438
439impl RectilinearEngine {
440    async fn make_embedder(
441        &self,
442        config: &Config,
443    ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
444        let key = self
445            .gemini_api_key
446            .as_deref()
447            .or(config.embedding.gemini_api_key.as_deref());
448
449        if let Some(api_key) = key {
450            Ok(Some(crate::embedding::Embedder::new_api(api_key).map_err(
451                |e| RectilinearError::Config {
452                    message: e.to_string(),
453                },
454            )?))
455        } else {
456            #[cfg(feature = "local-embeddings")]
457            {
458                let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
459                    message: e.to_string(),
460                })?;
461                Ok(Some(
462                    crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
463                        RectilinearError::Config {
464                            message: e.to_string(),
465                        }
466                    })?,
467                ))
468            }
469            #[cfg(not(feature = "local-embeddings"))]
470            {
471                Ok(None)
472            }
473        }
474    }
475}