1use crate::config::Config;
8use crate::db::Database;
9use crate::linear::LinearClient;
10use crate::search;
11use std::path::Path;
12use std::sync::Arc;
13
14#[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#[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#[derive(uniffi::Object)]
171pub struct RectilinearEngine {
172 db: Database,
173 linear_api_key: String,
174 gemini_api_key: Option<String>,
175 _runtime: Arc<tokio::runtime::Runtime>,
177}
178
179#[uniffi::export]
180impl RectilinearEngine {
181 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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
437impl 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}