1use 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#[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#[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#[derive(uniffi::Object)]
260pub struct RectilinearEngine {
261 db: Database,
262 linear_api_key: String,
263 gemini_api_key: Option<String>,
264 sync_progress: Mutex<Option<RtSyncProgress>>,
265 http_client: OnceCell<reqwest::Client>,
268}
269
270impl RectilinearEngine {
271 async fn client(&self) -> &reqwest::Client {
275 self.http_client
276 .get_or_init(|| async { reqwest::Client::new() })
277 .await
278 }
279}
280
281#[uniffi::export(async_runtime = "tokio")]
282impl RectilinearEngine {
283 #[uniffi::constructor]
285 pub fn new(
286 db_path: String,
287 linear_api_key: String,
288 gemini_api_key: Option<String>,
289 ) -> Result<Self, RectilinearError> {
290 let path = Path::new(&db_path);
291 if let Some(parent) = path.parent() {
292 std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
293 message: format!("Failed to create database directory: {e}"),
294 })?;
295 }
296
297 let db = Database::open(path)?;
298
299 Ok(Self {
300 db,
301 linear_api_key,
302 gemini_api_key,
303 sync_progress: Mutex::new(None),
304 http_client: OnceCell::new(),
305 })
306 }
307
308 pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
312 Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
313 }
314
315 pub fn get_triage_queue(
317 &self,
318 team: Option<String>,
319 include_completed: bool,
320 ) -> Result<Vec<RtIssue>, RectilinearError> {
321 let issues =
322 self.db
323 .get_unprioritized_issues(team.as_deref(), include_completed, "default")?;
324 Ok(issues.into_iter().map(RtIssue::from).collect())
325 }
326
327 pub fn search_fts(
329 &self,
330 query: String,
331 limit: u32,
332 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
333 let results = self.db.fts_search(&query, limit as usize, "default")?;
334 Ok(results
335 .into_iter()
336 .map(|fts| RtSearchResult {
337 issue_id: fts.issue_id,
338 identifier: fts.identifier,
339 title: fts.title,
340 state_name: fts.state_name,
341 priority: fts.priority,
342 score: fts.bm25_score,
343 similarity: None,
344 })
345 .collect())
346 }
347
348 pub fn count_issues(&self, team: Option<String>) -> Result<u64, RectilinearError> {
350 Ok(self.db.count_issues(team.as_deref(), "default")? as u64)
351 }
352
353 pub fn count_embedded_issues(&self, team: Option<String>) -> Result<u64, RectilinearError> {
355 Ok(self.db.count_embedded_issues(team.as_deref(), "default")? as u64)
356 }
357
358 pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
360 self.sync_progress.lock().unwrap().clone()
361 }
362
363 pub fn get_field_completeness(
365 &self,
366 team: Option<String>,
367 ) -> Result<RtFieldCompleteness, RectilinearError> {
368 let (total, desc, pri, labels, proj) =
369 self.db.get_field_completeness(team.as_deref(), "default")?;
370 Ok(RtFieldCompleteness {
371 total: total as u64,
372 with_description: desc as u64,
373 with_priority: pri as u64,
374 with_labels: labels as u64,
375 with_project: proj as u64,
376 })
377 }
378
379 pub fn list_all_issues(
381 &self,
382 team: Option<String>,
383 filter: Option<String>,
384 limit: u32,
385 offset: u32,
386 ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
387 let issues = self.db.list_all_issues(
388 team.as_deref(),
389 filter.as_deref(),
390 limit as usize,
391 offset as usize,
392 "default",
393 )?;
394 Ok(issues.into_iter().map(RtIssueSummary::from).collect())
395 }
396
397 pub fn list_synced_teams(&self) -> Result<Vec<RtTeamSummary>, RectilinearError> {
399 Ok(self
400 .db
401 .list_synced_teams("default")?
402 .into_iter()
403 .map(RtTeamSummary::from)
404 .collect())
405 }
406
407 pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
409 Ok(self
410 .db
411 .get_relations_enriched(&issue_id)?
412 .into_iter()
413 .map(RtRelation::from)
414 .collect())
415 }
416
417 pub fn get_active_issues(
419 &self,
420 team: String,
421 state_types: Vec<String>,
422 ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
423 let issues = self
424 .db
425 .get_issues_by_state_types(&team, &state_types, "default")?;
426 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
427 let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
428
429 let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
431 std::collections::HashMap::new();
432 for b in blockers {
433 let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
434 blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
435 identifier: b.identifier,
436 title: b.title,
437 state_name: b.state_name,
438 is_terminal,
439 });
440 }
441
442 Ok(issues
443 .into_iter()
444 .map(|issue| {
445 let labels: Vec<String> =
446 serde_json::from_str(&issue.labels_json).unwrap_or_default();
447 let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
448 RtIssueEnriched {
449 id: issue.id,
450 identifier: issue.identifier,
451 team_key: issue.team_key,
452 title: issue.title,
453 description: issue.description,
454 state_name: issue.state_name,
455 state_type: issue.state_type,
456 priority: issue.priority,
457 assignee_name: issue.assignee_name,
458 project_name: issue.project_name,
459 labels,
460 created_at: issue.created_at,
461 updated_at: issue.updated_at,
462 url: issue.url,
463 branch_name: issue.branch_name,
464 blocked_by,
465 }
466 })
467 .collect())
468 }
469
470 pub async fn list_teams(&self) -> Result<Vec<RtTeam>, RectilinearError> {
474 let client =
475 LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
476 let teams = client
477 .list_teams()
478 .await
479 .map_err(|e| RectilinearError::Api {
480 message: e.to_string(),
481 })?;
482 Ok(teams
483 .into_iter()
484 .map(|t| RtTeam {
485 id: t.id,
486 key: t.key,
487 name: t.name,
488 })
489 .collect())
490 }
491
492 pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
494 let api_key = self
495 .gemini_api_key
496 .as_deref()
497 .ok_or_else(|| RectilinearError::Config {
498 message: "Gemini API key not configured".into(),
499 })?;
500
501 crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
502 .map_err(|e| RectilinearError::Config {
503 message: e.to_string(),
504 })?
505 .test_api_key()
506 .await
507 .map_err(|e| RectilinearError::Api {
508 message: e.to_string(),
509 })
510 }
511
512 pub async fn sync_team(&self, team_key: String, full: bool) -> Result<u64, RectilinearError> {
514 self.set_sync_progress(Some(RtSyncProgress {
515 phase: RtSyncPhase::FetchingIssues,
516 completed: 0,
517 total: None,
518 }));
519
520 let client =
521 LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
522 let progress_state = &self.sync_progress;
523 let progress = move |count: usize| {
524 *progress_state.lock().unwrap() = Some(RtSyncProgress {
525 phase: RtSyncPhase::FetchingIssues,
526 completed: count as u64,
527 total: None,
528 });
529 };
530 let result = client
531 .sync_team(&self.db, &team_key, "default", full, false, Some(&progress))
532 .await
533 .map_err(|e| RectilinearError::Api {
534 message: e.to_string(),
535 });
536 self.set_sync_progress(None);
537 result.map(|count| count as u64)
538 }
539
540 pub async fn search_hybrid(
542 &self,
543 query: String,
544 team: Option<String>,
545 limit: u32,
546 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
547 let config = Config::load().unwrap_or_default();
548 let embedder = self.make_embedder(&config).await?;
549
550 let results = search::search(
551 &self.db,
552 &query,
553 search::SearchMode::Hybrid,
554 team.as_deref(),
555 None,
556 limit as usize,
557 embedder.as_ref(),
558 config.search.rrf_k,
559 "default",
560 )
561 .await?;
562
563 Ok(results.into_iter().map(RtSearchResult::from).collect())
564 }
565
566 pub async fn find_duplicates(
568 &self,
569 text: String,
570 team: Option<String>,
571 threshold: f32,
572 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
573 let config = Config::load().unwrap_or_default();
574 let embedder =
575 self.make_embedder(&config)
576 .await?
577 .ok_or_else(|| RectilinearError::Config {
578 message:
579 "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
580 .into(),
581 })?;
582
583 let results = search::find_duplicates(
584 &self.db,
585 &text,
586 team.as_deref(),
587 threshold,
588 10,
589 &embedder,
590 config.search.rrf_k,
591 "default",
592 )
593 .await?;
594
595 Ok(results.into_iter().map(RtSearchResult::from).collect())
596 }
597
598 pub async fn save_issue(
600 &self,
601 issue_id: String,
602 title: Option<String>,
603 description: Option<String>,
604 priority: Option<i32>,
605 state: Option<String>,
606 labels: Option<Vec<String>>,
607 ) -> Result<(), RectilinearError> {
608 let client =
609 LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
610
611 let state_id = if let Some(ref state_name) = state {
612 if let Some(issue) = self.db.get_issue(&issue_id)? {
614 Some(
615 client
616 .get_state_id(&issue.team_key, state_name)
617 .await
618 .map_err(|e| RectilinearError::Api {
619 message: e.to_string(),
620 })?,
621 )
622 } else {
623 None
624 }
625 } else {
626 None
627 };
628
629 let label_ids =
630 if let Some(ref label_names) = labels {
631 Some(client.get_label_ids(label_names).await.map_err(|e| {
632 RectilinearError::Api {
633 message: e.to_string(),
634 }
635 })?)
636 } else {
637 None
638 };
639
640 client
641 .update_issue(
642 &issue_id,
643 title.as_deref(),
644 description.as_deref(),
645 priority,
646 state_id.as_deref(),
647 label_ids.as_deref(),
648 None,
649 )
650 .await
651 .map_err(|e| RectilinearError::Api {
652 message: e.to_string(),
653 })?;
654
655 if let Ok((issue, relations)) = client.fetch_single_issue(&issue_id).await {
657 let _ = self.db.upsert_issue(&issue);
658 let _ = self.db.upsert_relations(&issue.id, &relations);
659 }
660
661 Ok(())
662 }
663
664 pub async fn add_comment(
666 &self,
667 issue_id: String,
668 body: String,
669 ) -> Result<(), RectilinearError> {
670 let client =
671 LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
672 client
673 .add_comment(&issue_id, &body)
674 .await
675 .map_err(|e| RectilinearError::Api {
676 message: e.to_string(),
677 })
678 }
679
680 pub async fn refresh_issue(
683 &self,
684 id_or_identifier: String,
685 ) -> Result<Option<RtIssue>, RectilinearError> {
686 let client =
687 LinearClient::with_http_client(self.client().await.clone(), &self.linear_api_key);
688
689 let result = if id_or_identifier.contains('-')
690 && id_or_identifier
691 .chars()
692 .last()
693 .is_some_and(|c| c.is_ascii_digit())
694 {
695 client
696 .fetch_issue_by_identifier(&id_or_identifier)
697 .await
698 .map_err(|e| RectilinearError::Api {
699 message: e.to_string(),
700 })?
701 } else {
702 Some(
703 client
704 .fetch_single_issue(&id_or_identifier)
705 .await
706 .map_err(|e| RectilinearError::Api {
707 message: e.to_string(),
708 })?,
709 )
710 };
711
712 if let Some((issue, relations)) = result {
713 self.db.upsert_issue(&issue)?;
714 self.db.upsert_relations(&issue.id, &relations)?;
715 Ok(Some(RtIssue::from(issue)))
716 } else {
717 Ok(None)
718 }
719 }
720
721 pub async fn embed_issues(
724 &self,
725 team: Option<String>,
726 limit: u32,
727 ) -> Result<u64, RectilinearError> {
728 let config = Config::load().unwrap_or_default();
729 let embedder =
730 self.make_embedder(&config)
731 .await?
732 .ok_or_else(|| {
733 RectilinearError::Config {
734 message:
735 "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
736 .into(),
737 }
738 })?;
739
740 let model_name = embedder.backend_name().to_string();
741 let issues = self
742 .db
743 .get_issues_needing_embedding(team.as_deref(), false, "default")?;
744
745 let to_process = if limit > 0 {
746 &issues[..std::cmp::min(issues.len(), limit as usize)]
747 } else {
748 &issues
749 };
750 let total = to_process.len() as u64;
751
752 self.set_sync_progress(Some(RtSyncProgress {
753 phase: RtSyncPhase::GeneratingEmbeddings,
754 completed: 0,
755 total: Some(total),
756 }));
757
758 let result: Result<u64, RectilinearError> = async {
759 let mut count = 0u64;
760 for issue in to_process {
761 if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
763 if existing_model == model_name {
764 continue;
765 }
766 }
767
768 let chunks = crate::embedding::chunk_text(
769 &issue.title,
770 issue.description.as_deref().unwrap_or(""),
771 512,
772 64,
773 );
774 let embeddings =
775 embedder
776 .embed_batch(&chunks)
777 .await
778 .map_err(|e| RectilinearError::Api {
779 message: e.to_string(),
780 })?;
781
782 let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
783 .into_iter()
784 .zip(embeddings.iter())
785 .enumerate()
786 .map(|(idx, (text, emb))| {
787 (idx, text, crate::embedding::embedding_to_bytes(emb))
788 })
789 .collect();
790
791 self.db
792 .upsert_chunks_with_model(&issue.id, &chunk_data, &model_name)?;
793 count += 1;
794 self.set_sync_progress(Some(RtSyncProgress {
795 phase: RtSyncPhase::GeneratingEmbeddings,
796 completed: count,
797 total: Some(total),
798 }));
799 }
800
801 Ok(count)
802 }
803 .await;
804
805 self.set_sync_progress(None);
806 result
807 }
808}
809
810impl RectilinearEngine {
813 fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
814 *self.sync_progress.lock().unwrap() = progress;
815 }
816
817 async fn make_embedder(
818 &self,
819 config: &Config,
820 ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
821 let key = self
822 .gemini_api_key
823 .as_deref()
824 .or(config.embedding.gemini_api_key.as_deref());
825
826 if let Some(api_key) = key {
827 Ok(Some(
828 crate::embedding::Embedder::new_api_with_http_client(
829 self.client().await.clone(),
830 api_key,
831 )
832 .map_err(|e| RectilinearError::Config {
833 message: e.to_string(),
834 })?,
835 ))
836 } else {
837 #[cfg(feature = "local-embeddings")]
838 {
839 let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
840 message: e.to_string(),
841 })?;
842 Ok(Some(
843 crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
844 RectilinearError::Config {
845 message: e.to_string(),
846 }
847 })?,
848 ))
849 }
850 #[cfg(not(feature = "local-embeddings"))]
851 {
852 Ok(None)
853 }
854 }
855 }
856}