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 project_id: Option<String>,
52 pub project_milestone_id: Option<String>,
53 pub project_milestone_name: Option<String>,
54 pub cycle_id: Option<String>,
55 pub cycle_name: Option<String>,
56 pub labels: Vec<String>,
57 pub created_at: String,
58 pub updated_at: String,
59 pub url: String,
60 pub branch_name: Option<String>,
61}
62
63impl From<crate::db::Issue> for RtIssue {
64 fn from(issue: crate::db::Issue) -> Self {
65 let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
66 Self {
67 id: issue.id,
68 identifier: issue.identifier,
69 team_key: issue.team_key,
70 title: issue.title,
71 description: issue.description,
72 state_name: issue.state_name,
73 state_type: issue.state_type,
74 priority: issue.priority,
75 assignee_name: issue.assignee_name,
76 project_name: issue.project_name,
77 project_id: issue.project_id,
78 project_milestone_id: issue.project_milestone_id,
79 project_milestone_name: issue.project_milestone_name,
80 cycle_id: issue.cycle_id,
81 cycle_name: issue.cycle_name,
82 labels,
83 created_at: issue.created_at,
84 updated_at: issue.updated_at,
85 url: issue.url,
86 branch_name: issue.branch_name,
87 }
88 }
89}
90
91#[derive(uniffi::Record)]
92pub struct RtSearchResult {
93 pub issue_id: String,
94 pub identifier: String,
95 pub title: String,
96 pub state_name: String,
97 pub priority: i32,
98 pub score: f64,
99 pub similarity: Option<f32>,
100}
101
102impl From<search::SearchResult> for RtSearchResult {
103 fn from(sr: search::SearchResult) -> Self {
104 Self {
105 issue_id: sr.issue_id,
106 identifier: sr.identifier,
107 title: sr.title,
108 state_name: sr.state_name,
109 priority: sr.priority,
110 score: sr.score,
111 similarity: sr.similarity,
112 }
113 }
114}
115
116#[derive(uniffi::Record)]
117pub struct RtRelation {
118 pub relation_type: String,
119 pub issue_identifier: String,
120 pub issue_title: String,
121 pub issue_state: String,
122 pub issue_url: String,
123}
124
125impl From<crate::db::EnrichedRelation> for RtRelation {
126 fn from(rel: crate::db::EnrichedRelation) -> Self {
127 Self {
128 relation_type: rel.relation_type,
129 issue_identifier: rel.issue_identifier,
130 issue_title: rel.issue_title,
131 issue_state: rel.issue_state,
132 issue_url: rel.issue_url,
133 }
134 }
135}
136
137#[derive(uniffi::Record)]
138pub struct RtBlocker {
139 pub identifier: String,
140 pub title: String,
141 pub state_name: String,
142 pub is_terminal: bool,
143}
144
145#[derive(uniffi::Record)]
146pub struct RtIssueEnriched {
147 pub id: String,
148 pub identifier: String,
149 pub team_key: String,
150 pub title: String,
151 pub description: Option<String>,
152 pub state_name: String,
153 pub state_type: String,
154 pub priority: i32,
155 pub assignee_name: Option<String>,
156 pub project_name: Option<String>,
157 pub project_id: Option<String>,
158 pub project_milestone_id: Option<String>,
159 pub project_milestone_name: Option<String>,
160 pub cycle_id: Option<String>,
161 pub cycle_name: Option<String>,
162 pub labels: Vec<String>,
163 pub created_at: String,
164 pub updated_at: String,
165 pub url: String,
166 pub branch_name: Option<String>,
167 pub blocked_by: Vec<RtBlocker>,
168}
169
170#[derive(uniffi::Record)]
171pub struct RtTeam {
172 pub id: String,
173 pub key: String,
174 pub name: String,
175}
176
177#[derive(uniffi::Enum)]
178pub enum RtSearchMode {
179 Fts,
180 Vector,
181 Hybrid,
182}
183
184#[derive(uniffi::Record)]
185pub struct RtFieldCompleteness {
186 pub total: u64,
187 pub with_description: u64,
188 pub with_priority: u64,
189 pub with_labels: u64,
190 pub with_project: u64,
191}
192
193#[derive(uniffi::Record)]
194pub struct RtIssueSummary {
195 pub id: String,
196 pub identifier: String,
197 pub team_key: String,
198 pub title: String,
199 pub state_name: String,
200 pub state_type: String,
201 pub priority: i32,
202 pub project_name: Option<String>,
203 pub labels: Vec<String>,
204 pub updated_at: String,
205 pub url: String,
206 pub has_description: bool,
207 pub has_embedding: bool,
208}
209
210impl From<crate::db::IssueSummary> for RtIssueSummary {
211 fn from(s: crate::db::IssueSummary) -> Self {
212 Self {
213 id: s.id,
214 identifier: s.identifier,
215 team_key: s.team_key,
216 title: s.title,
217 state_name: s.state_name,
218 state_type: s.state_type,
219 priority: s.priority,
220 project_name: s.project_name,
221 labels: s.labels,
222 updated_at: s.updated_at,
223 url: s.url,
224 has_description: s.has_description,
225 has_embedding: s.has_embedding,
226 }
227 }
228}
229
230#[derive(uniffi::Record)]
231pub struct RtTeamSummary {
232 pub key: String,
233 pub issue_count: u64,
234 pub embedded_count: u64,
235 pub last_synced_at: Option<String>,
236}
237
238#[derive(uniffi::Record)]
239pub struct RtCreateIssueResult {
240 pub id: String,
241 pub identifier: String,
242}
243
244#[derive(uniffi::Record)]
245pub struct RtCreateIssueInput {
246 pub team_key: String,
247 pub title: String,
248 pub description: Option<String>,
249 pub priority: Option<i32>,
250 pub label_ids: Vec<String>,
251 pub parent_id: Option<String>,
252 pub project_id: Option<String>,
253 pub project_milestone_id: Option<String>,
254}
255
256#[derive(uniffi::Record)]
257pub struct RtProjectTeam {
258 pub id: String,
259 pub key: String,
260 pub name: String,
261}
262
263#[derive(uniffi::Record)]
264pub struct RtProjectMember {
265 pub id: String,
266 pub name: String,
267}
268
269#[derive(uniffi::Record)]
270pub struct RtProjectLabel {
271 pub id: String,
272 pub name: String,
273 pub color: String,
274 pub description: Option<String>,
275}
276
277#[derive(uniffi::Record)]
278pub struct RtProject {
279 pub id: String,
280 pub workspace_id: String,
281 pub slug_id: String,
282 pub name: String,
283 pub description: String,
284 pub content: Option<String>,
285 pub icon: Option<String>,
286 pub color: String,
287 pub status_id: String,
288 pub status_name: String,
289 pub status_type: String,
290 pub status_color: String,
291 pub priority: i32,
292 pub start_date: Option<String>,
293 pub target_date: Option<String>,
294 pub lead_id: Option<String>,
295 pub lead_name: Option<String>,
296 pub created_at: String,
297 pub updated_at: String,
298 pub archived_at: Option<String>,
299 pub url: String,
300 pub progress: f64,
301 pub teams: Vec<RtProjectTeam>,
302 pub members: Vec<RtProjectMember>,
303 pub labels: Vec<RtProjectLabel>,
304}
305
306impl From<crate::db::Project> for RtProject {
307 fn from(project: crate::db::Project) -> Self {
308 Self {
309 id: project.id,
310 workspace_id: project.workspace_id,
311 slug_id: project.slug_id,
312 name: project.name,
313 description: project.description,
314 content: project.content,
315 icon: project.icon,
316 color: project.color,
317 status_id: project.status_id,
318 status_name: project.status_name,
319 status_type: project.status_type,
320 status_color: project.status_color,
321 priority: project.priority,
322 start_date: project.start_date,
323 target_date: project.target_date,
324 lead_id: project.lead_id,
325 lead_name: project.lead_name,
326 created_at: project.created_at,
327 updated_at: project.updated_at,
328 archived_at: project.archived_at,
329 url: project.url,
330 progress: project.progress,
331 teams: project
332 .teams
333 .into_iter()
334 .map(|team| RtProjectTeam {
335 id: team.id,
336 key: team.key,
337 name: team.name,
338 })
339 .collect(),
340 members: project
341 .members
342 .into_iter()
343 .map(|member| RtProjectMember {
344 id: member.id,
345 name: member.name,
346 })
347 .collect(),
348 labels: project
349 .labels
350 .into_iter()
351 .map(|label| RtProjectLabel {
352 id: label.id,
353 name: label.name,
354 color: label.color,
355 description: label.description,
356 })
357 .collect(),
358 }
359 }
360}
361
362#[derive(uniffi::Record)]
363pub struct RtProjectMilestone {
364 pub id: String,
365 pub workspace_id: String,
366 pub project_id: String,
367 pub project_name: String,
368 pub name: String,
369 pub description: Option<String>,
370 pub target_date: Option<String>,
371 pub status: String,
372 pub progress: f64,
373 pub sort_order: f64,
374 pub created_at: String,
375 pub updated_at: String,
376 pub archived_at: Option<String>,
377}
378
379impl From<crate::db::ProjectMilestone> for RtProjectMilestone {
380 fn from(milestone: crate::db::ProjectMilestone) -> Self {
381 Self {
382 id: milestone.id,
383 workspace_id: milestone.workspace_id,
384 project_id: milestone.project_id,
385 project_name: milestone.project_name,
386 name: milestone.name,
387 description: milestone.description,
388 target_date: milestone.target_date,
389 status: milestone.status,
390 progress: milestone.progress,
391 sort_order: milestone.sort_order,
392 created_at: milestone.created_at,
393 updated_at: milestone.updated_at,
394 archived_at: milestone.archived_at,
395 }
396 }
397}
398
399#[derive(uniffi::Record)]
400pub struct RtProjectBundle {
401 pub project: RtProject,
402 pub milestones: Vec<RtProjectMilestone>,
403 pub issues: Vec<RtIssue>,
404}
405
406impl From<crate::db::ProjectBundle> for RtProjectBundle {
407 fn from(bundle: crate::db::ProjectBundle) -> Self {
408 Self {
409 project: bundle.project.into(),
410 milestones: bundle.milestones.into_iter().map(Into::into).collect(),
411 issues: bundle.issues.into_iter().map(Into::into).collect(),
412 }
413 }
414}
415
416#[derive(uniffi::Record)]
417pub struct RtProjectMilestoneBundle {
418 pub project: RtProject,
419 pub milestone: RtProjectMilestone,
420 pub issues: Vec<RtIssue>,
421}
422
423impl From<crate::db::ProjectMilestoneBundle> for RtProjectMilestoneBundle {
424 fn from(bundle: crate::db::ProjectMilestoneBundle) -> Self {
425 Self {
426 project: bundle.project.into(),
427 milestone: bundle.milestone.into(),
428 issues: bundle.issues.into_iter().map(Into::into).collect(),
429 }
430 }
431}
432
433#[derive(uniffi::Record)]
434pub struct RtProjectSyncResult {
435 pub projects: u64,
436 pub milestones: u64,
437}
438
439#[derive(uniffi::Record)]
440pub struct RtCreateProjectInput {
441 pub name: String,
442 pub team_ids: Vec<String>,
443 pub description: Option<String>,
444 pub content: Option<String>,
445 pub icon: Option<String>,
446 pub color: Option<String>,
447 pub status_id: Option<String>,
448 pub priority: Option<i32>,
449 pub lead_id: Option<String>,
450 pub start_date: Option<String>,
451 pub target_date: Option<String>,
452 pub member_ids: Option<Vec<String>>,
453 pub label_ids: Option<Vec<String>>,
454}
455
456#[derive(uniffi::Record)]
457pub struct RtUpdateProjectInput {
458 pub name: Option<String>,
459 pub team_ids: Option<Vec<String>>,
460 pub description: Option<String>,
461 pub content: Option<String>,
462 pub icon: Option<String>,
463 pub color: Option<String>,
464 pub status_id: Option<String>,
465 pub priority: Option<i32>,
466 pub lead_id: Option<String>,
467 pub start_date: Option<String>,
468 pub target_date: Option<String>,
469 pub member_ids: Option<Vec<String>>,
470 pub label_ids: Option<Vec<String>>,
471}
472
473#[derive(uniffi::Record)]
474pub struct RtCreateProjectMilestoneInput {
475 pub project_id: String,
476 pub name: String,
477 pub description: Option<String>,
478 pub target_date: Option<String>,
479 pub sort_order: Option<f64>,
480}
481
482#[derive(uniffi::Record)]
483pub struct RtUpdateProjectMilestoneInput {
484 pub project_id: Option<String>,
485 pub name: Option<String>,
486 pub description: Option<String>,
487 pub target_date: Option<String>,
488 pub sort_order: Option<f64>,
489}
490
491#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
492pub enum RtSyncPhase {
493 FetchingIssues,
494 GeneratingEmbeddings,
495}
496
497#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
498pub struct RtSyncProgress {
499 pub phase: RtSyncPhase,
500 pub completed: u64,
501 pub total: Option<u64>,
502}
503
504impl From<crate::db::TeamSummary> for RtTeamSummary {
505 fn from(t: crate::db::TeamSummary) -> Self {
506 Self {
507 key: t.key,
508 issue_count: t.issue_count as u64,
509 embedded_count: t.embedded_count as u64,
510 last_synced_at: t.last_synced_at,
511 }
512 }
513}
514
515impl From<RtSearchMode> for search::SearchMode {
516 fn from(mode: RtSearchMode) -> Self {
517 match mode {
518 RtSearchMode::Fts => search::SearchMode::Fts,
519 RtSearchMode::Vector => search::SearchMode::Vector,
520 RtSearchMode::Hybrid => search::SearchMode::Hybrid,
521 }
522 }
523}
524
525#[derive(uniffi::Object)]
528pub struct RectilinearEngine {
529 db: Database,
530 gemini_api_key: Option<String>,
531 sync_progress: Mutex<Option<RtSyncProgress>>,
532 http_client: OnceCell<reqwest::Client>,
535}
536
537impl RectilinearEngine {
538 async fn client(&self) -> &reqwest::Client {
542 self.http_client
543 .get_or_init(|| async { reqwest::Client::new() })
544 .await
545 }
546}
547
548#[uniffi::export(async_runtime = "tokio")]
549impl RectilinearEngine {
550 #[uniffi::constructor]
553 pub fn new(
554 db_path: String,
555 gemini_api_key: Option<String>,
556 ) -> Result<Self, RectilinearError> {
557 let path = Path::new(&db_path);
558 if let Some(parent) = path.parent() {
559 std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
560 message: format!("Failed to create database directory: {e}"),
561 })?;
562 }
563
564 let db = Database::open(path)?;
565
566 Ok(Self {
567 db,
568 gemini_api_key,
569 sync_progress: Mutex::new(None),
570 http_client: OnceCell::new(),
571 })
572 }
573
574 pub fn linear_api_key_for_workspace(
576 &self,
577 workspace_id: &str,
578 ) -> Result<String, RectilinearError> {
579 let config = Config::load().map_err(|e| RectilinearError::Config {
580 message: e.to_string(),
581 })?;
582 config
583 .workspace_api_key(workspace_id)
584 .map_err(|e| RectilinearError::Config {
585 message: e.to_string(),
586 })
587 }
588
589 pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
591 let config = Config::load().map_err(|e| RectilinearError::Config {
592 message: e.to_string(),
593 })?;
594 Ok(config.workspace_names())
595 }
596
597 pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
599 let config = Config::load().map_err(|e| RectilinearError::Config {
600 message: e.to_string(),
601 })?;
602 config
603 .resolve_active_workspace()
604 .map_err(|e| RectilinearError::Config {
605 message: e.to_string(),
606 })
607 }
608
609 pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
613 Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
614 }
615
616 pub fn list_projects(
618 &self,
619 workspace_id: String,
620 include_archived: bool,
621 ) -> Result<Vec<RtProject>, RectilinearError> {
622 Ok(self
623 .db
624 .list_projects(&workspace_id, include_archived)?
625 .into_iter()
626 .map(Into::into)
627 .collect())
628 }
629
630 pub fn get_project(
632 &self,
633 id_or_name: String,
634 workspace_id: String,
635 ) -> Result<Option<RtProject>, RectilinearError> {
636 Ok(self
637 .db
638 .get_project(&workspace_id, &id_or_name)?
639 .map(Into::into))
640 }
641
642 pub fn list_project_milestones(
644 &self,
645 project_id: String,
646 ) -> Result<Vec<RtProjectMilestone>, RectilinearError> {
647 Ok(self
648 .db
649 .list_project_milestones(&project_id)?
650 .into_iter()
651 .map(Into::into)
652 .collect())
653 }
654
655 pub fn get_project_bundle(
657 &self,
658 id_or_name: String,
659 workspace_id: String,
660 ) -> Result<Option<RtProjectBundle>, RectilinearError> {
661 Ok(self
662 .db
663 .get_project_bundle(&workspace_id, &id_or_name)?
664 .map(Into::into))
665 }
666
667 pub fn get_project_milestone_bundle(
669 &self,
670 id_or_name: String,
671 project_id: Option<String>,
672 workspace_id: String,
673 ) -> Result<Option<RtProjectMilestoneBundle>, RectilinearError> {
674 Ok(self
675 .db
676 .get_project_milestone_bundle(
677 &workspace_id,
678 &id_or_name,
679 project_id.as_deref(),
680 )?
681 .map(Into::into))
682 }
683
684 pub fn get_triage_queue(
686 &self,
687 team: Option<String>,
688 include_completed: bool,
689 workspace_id: String,
690 ) -> Result<Vec<RtIssue>, RectilinearError> {
691 let issues =
692 self.db
693 .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
694 Ok(issues.into_iter().map(RtIssue::from).collect())
695 }
696
697 pub fn search_fts(
699 &self,
700 query: String,
701 limit: u32,
702 workspace_id: String,
703 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
704 let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
705 Ok(results
706 .into_iter()
707 .map(|fts| RtSearchResult {
708 issue_id: fts.issue_id,
709 identifier: fts.identifier,
710 title: fts.title,
711 state_name: fts.state_name,
712 priority: fts.priority,
713 score: fts.bm25_score,
714 similarity: None,
715 })
716 .collect())
717 }
718
719 pub fn count_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
721 Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
722 }
723
724 pub fn count_embedded_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
726 Ok(self.db.count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
727 }
728
729 pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
731 self.sync_progress.lock().unwrap().clone()
732 }
733
734 pub fn get_field_completeness(
736 &self,
737 team: Option<String>,
738 workspace_id: String,
739 ) -> Result<RtFieldCompleteness, RectilinearError> {
740 let (total, desc, pri, labels, proj) =
741 self.db.get_field_completeness(team.as_deref(), &workspace_id)?;
742 Ok(RtFieldCompleteness {
743 total: total as u64,
744 with_description: desc as u64,
745 with_priority: pri as u64,
746 with_labels: labels as u64,
747 with_project: proj as u64,
748 })
749 }
750
751 pub fn list_all_issues(
753 &self,
754 team: Option<String>,
755 filter: Option<String>,
756 limit: u32,
757 offset: u32,
758 workspace_id: String,
759 ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
760 let issues = self.db.list_all_issues(
761 team.as_deref(),
762 filter.as_deref(),
763 limit as usize,
764 offset as usize,
765 &workspace_id,
766 )?;
767 Ok(issues.into_iter().map(RtIssueSummary::from).collect())
768 }
769
770 pub fn list_synced_teams(&self, workspace_id: String) -> Result<Vec<RtTeamSummary>, RectilinearError> {
772 Ok(self
773 .db
774 .list_synced_teams(&workspace_id)?
775 .into_iter()
776 .map(RtTeamSummary::from)
777 .collect())
778 }
779
780 pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
782 Ok(self
783 .db
784 .get_relations_enriched(&issue_id)?
785 .into_iter()
786 .map(RtRelation::from)
787 .collect())
788 }
789
790 pub fn get_active_issues(
792 &self,
793 team: String,
794 state_types: Vec<String>,
795 workspace_id: String,
796 ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
797 let issues = self
798 .db
799 .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
800 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
801 let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
802
803 let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
805 std::collections::HashMap::new();
806 for b in blockers {
807 let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
808 blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
809 identifier: b.identifier,
810 title: b.title,
811 state_name: b.state_name,
812 is_terminal,
813 });
814 }
815
816 Ok(issues
817 .into_iter()
818 .map(|issue| {
819 let labels: Vec<String> =
820 serde_json::from_str(&issue.labels_json).unwrap_or_default();
821 let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
822 RtIssueEnriched {
823 id: issue.id,
824 identifier: issue.identifier,
825 team_key: issue.team_key,
826 title: issue.title,
827 description: issue.description,
828 state_name: issue.state_name,
829 state_type: issue.state_type,
830 priority: issue.priority,
831 assignee_name: issue.assignee_name,
832 project_name: issue.project_name,
833 project_id: issue.project_id,
834 project_milestone_id: issue.project_milestone_id,
835 project_milestone_name: issue.project_milestone_name,
836 cycle_id: issue.cycle_id,
837 cycle_name: issue.cycle_name,
838 labels,
839 created_at: issue.created_at,
840 updated_at: issue.updated_at,
841 url: issue.url,
842 branch_name: issue.branch_name,
843 blocked_by,
844 }
845 })
846 .collect())
847 }
848
849 pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
853 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
854 let client =
855 LinearClient::with_http_client(self.client().await.clone(), &api_key);
856 let teams = client
857 .list_teams()
858 .await
859 .map_err(|e| RectilinearError::Api {
860 message: e.to_string(),
861 })?;
862 Ok(teams
863 .into_iter()
864 .map(|t| RtTeam {
865 id: t.id,
866 key: t.key,
867 name: t.name,
868 })
869 .collect())
870 }
871
872 pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
874 let api_key = self
875 .gemini_api_key
876 .as_deref()
877 .ok_or_else(|| RectilinearError::Config {
878 message: "Gemini API key not configured".into(),
879 })?;
880
881 crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
882 .map_err(|e| RectilinearError::Config {
883 message: e.to_string(),
884 })?
885 .test_api_key()
886 .await
887 .map_err(|e| RectilinearError::Api {
888 message: e.to_string(),
889 })
890 }
891
892 pub async fn sync_projects(
894 &self,
895 workspace_id: String,
896 ) -> Result<RtProjectSyncResult, RectilinearError> {
897 let client = self.linear_client(&workspace_id).await?;
898 let (projects, milestones) = client
899 .sync_projects(&self.db, &workspace_id)
900 .await
901 .map_err(api_error)?;
902 Ok(RtProjectSyncResult {
903 projects: projects as u64,
904 milestones: milestones as u64,
905 })
906 }
907
908 pub async fn import_project(
910 &self,
911 id_or_name: String,
912 workspace_id: String,
913 ) -> Result<RtProjectBundle, RectilinearError> {
914 let client = self.linear_client(&workspace_id).await?;
915 client
916 .import_project(&self.db, &workspace_id, &id_or_name)
917 .await
918 .map(Into::into)
919 .map_err(api_error)
920 }
921
922 pub async fn import_project_milestone(
924 &self,
925 id_or_name: String,
926 project_id: Option<String>,
927 workspace_id: String,
928 ) -> Result<RtProjectMilestoneBundle, RectilinearError> {
929 let client = self.linear_client(&workspace_id).await?;
930 client
931 .import_project_milestone(
932 &self.db,
933 &workspace_id,
934 project_id.as_deref(),
935 &id_or_name,
936 )
937 .await
938 .map(Into::into)
939 .map_err(api_error)
940 }
941
942 pub async fn create_project(
944 &self,
945 input: RtCreateProjectInput,
946 workspace_id: String,
947 ) -> Result<RtProject, RectilinearError> {
948 let client = self.linear_client(&workspace_id).await?;
949 let input = crate::linear::CreateProjectInput {
950 name: input.name,
951 team_ids: input.team_ids,
952 description: input.description,
953 content: input.content,
954 icon: input.icon,
955 color: input.color,
956 status_id: input.status_id,
957 priority: input.priority,
958 lead_id: input.lead_id,
959 start_date: input.start_date,
960 target_date: input.target_date,
961 member_ids: input.member_ids,
962 label_ids: input.label_ids,
963 };
964 let id = client.create_project(&input).await.map_err(api_error)?;
965 let project = client
966 .fetch_project(&id, &workspace_id)
967 .await
968 .map_err(api_error)?;
969 self.db.upsert_project(&project)?;
970 Ok(project.into())
971 }
972
973 pub async fn update_project(
975 &self,
976 id_or_name: String,
977 input: RtUpdateProjectInput,
978 workspace_id: String,
979 ) -> Result<RtProject, RectilinearError> {
980 let client = self.linear_client(&workspace_id).await?;
981 let id = client
982 .find_project_by_name(&id_or_name)
983 .await
984 .map_err(api_error)?;
985 let input = crate::linear::UpdateProjectInput {
986 name: input.name,
987 team_ids: input.team_ids,
988 description: input.description,
989 content: input.content,
990 icon: input.icon,
991 color: input.color,
992 status_id: input.status_id,
993 priority: input.priority,
994 lead_id: input.lead_id,
995 start_date: input.start_date,
996 target_date: input.target_date,
997 member_ids: input.member_ids,
998 label_ids: input.label_ids,
999 };
1000 client.update_project(&id, &input).await.map_err(api_error)?;
1001 let project = client
1002 .fetch_project(&id, &workspace_id)
1003 .await
1004 .map_err(api_error)?;
1005 self.db.upsert_project(&project)?;
1006 Ok(project.into())
1007 }
1008
1009 pub async fn delete_project(
1011 &self,
1012 id_or_name: String,
1013 workspace_id: String,
1014 ) -> Result<(), RectilinearError> {
1015 let client = self.linear_client(&workspace_id).await?;
1016 let id = client
1017 .find_project_by_name(&id_or_name)
1018 .await
1019 .map_err(api_error)?;
1020 client.delete_project(&id).await.map_err(api_error)?;
1021 self.db.delete_project_local(&id)?;
1022 Ok(())
1023 }
1024
1025 pub async fn create_project_milestone(
1027 &self,
1028 input: RtCreateProjectMilestoneInput,
1029 workspace_id: String,
1030 ) -> Result<RtProjectMilestone, RectilinearError> {
1031 let client = self.linear_client(&workspace_id).await?;
1032 let input = crate::linear::CreateProjectMilestoneInput {
1033 project_id: input.project_id,
1034 name: input.name,
1035 description: input.description,
1036 target_date: input.target_date,
1037 sort_order: input.sort_order,
1038 };
1039 let id = client
1040 .create_project_milestone(&input)
1041 .await
1042 .map_err(api_error)?;
1043 self.cache_project_milestone(&client, &id, &workspace_id)
1044 .await
1045 }
1046
1047 pub async fn update_project_milestone(
1049 &self,
1050 id_or_name: String,
1051 owning_project_id: Option<String>,
1052 input: RtUpdateProjectMilestoneInput,
1053 workspace_id: String,
1054 ) -> Result<RtProjectMilestone, RectilinearError> {
1055 let client = self.linear_client(&workspace_id).await?;
1056 let id = client
1057 .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1058 .await
1059 .map_err(api_error)?;
1060 let input = crate::linear::UpdateProjectMilestoneInput {
1061 project_id: input.project_id,
1062 name: input.name,
1063 description: input.description,
1064 target_date: input.target_date,
1065 sort_order: input.sort_order,
1066 };
1067 client
1068 .update_project_milestone(&id, &input)
1069 .await
1070 .map_err(api_error)?;
1071 self.cache_project_milestone(&client, &id, &workspace_id)
1072 .await
1073 }
1074
1075 pub async fn delete_project_milestone(
1077 &self,
1078 id_or_name: String,
1079 owning_project_id: Option<String>,
1080 workspace_id: String,
1081 ) -> Result<(), RectilinearError> {
1082 let client = self.linear_client(&workspace_id).await?;
1083 let id = client
1084 .find_project_milestone(owning_project_id.as_deref(), &id_or_name)
1085 .await
1086 .map_err(api_error)?;
1087 client
1088 .delete_project_milestone(&id)
1089 .await
1090 .map_err(api_error)?;
1091 self.db.delete_project_milestone_local(&id)?;
1092 Ok(())
1093 }
1094
1095 pub async fn set_issue_project_context(
1097 &self,
1098 issue_id: String,
1099 project_id: Option<String>,
1100 project_milestone_id: Option<String>,
1101 workspace_id: String,
1102 ) -> Result<RtIssue, RectilinearError> {
1103 let client = self.linear_client(&workspace_id).await?;
1104 let project_id = match (project_id, project_milestone_id.as_deref()) {
1105 (Some(project_id), _) => Some(project_id),
1106 (None, Some(milestone_id)) => Some(
1107 client
1108 .fetch_project_milestone(milestone_id, &workspace_id)
1109 .await
1110 .map_err(api_error)?
1111 .project_id,
1112 ),
1113 (None, None) => None,
1114 };
1115 let project_value = project_id.unwrap_or_default();
1116 let milestone_value = project_milestone_id.unwrap_or_default();
1117 client
1118 .update_issue(
1119 &issue_id,
1120 crate::linear::UpdateIssueInput {
1121 project_id: Some(&project_value),
1122 project_milestone_id: Some(&milestone_value),
1123 ..Default::default()
1124 },
1125 )
1126 .await
1127 .map_err(api_error)?;
1128 let (mut issue, relations, label_ids) = client
1129 .fetch_single_issue(&issue_id)
1130 .await
1131 .map_err(api_error)?;
1132 issue.workspace_id = workspace_id;
1133 self.db.upsert_issue(&issue)?;
1134 self.db.upsert_relations(&issue.id, &relations)?;
1135 self.db.replace_issue_labels(&issue.id, &label_ids)?;
1136 Ok(issue.into())
1137 }
1138
1139 pub async fn sync_team(&self, team_key: String, full: bool, workspace_id: String) -> Result<u64, RectilinearError> {
1141 self.set_sync_progress(Some(RtSyncProgress {
1142 phase: RtSyncPhase::FetchingIssues,
1143 completed: 0,
1144 total: None,
1145 }));
1146
1147 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1148 let client =
1149 LinearClient::with_http_client(self.client().await.clone(), &api_key);
1150 let progress_state = &self.sync_progress;
1151 let progress = move |count: usize| {
1152 *progress_state.lock().unwrap() = Some(RtSyncProgress {
1153 phase: RtSyncPhase::FetchingIssues,
1154 completed: count as u64,
1155 total: None,
1156 });
1157 };
1158 let result = client
1159 .sync_team(&self.db, &team_key, &workspace_id, full, false, Some(&progress))
1160 .await
1161 .map_err(|e| RectilinearError::Api {
1162 message: e.to_string(),
1163 });
1164 self.set_sync_progress(None);
1165 result.map(|count| count as u64)
1166 }
1167
1168 pub async fn search_hybrid(
1170 &self,
1171 query: String,
1172 team: Option<String>,
1173 limit: u32,
1174 workspace_id: String,
1175 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1176 let config = Config::load().unwrap_or_default();
1177 let embedder = self.make_embedder(&config).await?;
1178
1179 let results = search::search(
1180 &self.db,
1181 search::SearchParams {
1182 query: &query,
1183 mode: search::SearchMode::Hybrid,
1184 team_key: team.as_deref(),
1185 state_filter: None,
1186 label_ids: None,
1187 limit: limit as usize,
1188 embedder: embedder.as_ref(),
1189 rrf_k: config.search.rrf_k,
1190 workspace_id: &workspace_id,
1191 },
1192 )
1193 .await?;
1194
1195 Ok(results.into_iter().map(RtSearchResult::from).collect())
1196 }
1197
1198 pub async fn find_duplicates(
1200 &self,
1201 text: String,
1202 team: Option<String>,
1203 threshold: f32,
1204 workspace_id: String,
1205 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
1206 let config = Config::load().unwrap_or_default();
1207 let embedder =
1208 self.make_embedder(&config)
1209 .await?
1210 .ok_or_else(|| RectilinearError::Config {
1211 message:
1212 "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
1213 .into(),
1214 })?;
1215
1216 let results = search::find_duplicates(
1217 &self.db,
1218 &text,
1219 team.as_deref(),
1220 threshold,
1221 10,
1222 &embedder,
1223 config.search.rrf_k,
1224 &workspace_id,
1225 )
1226 .await?;
1227
1228 Ok(results.into_iter().map(RtSearchResult::from).collect())
1229 }
1230
1231 pub async fn save_issue(
1233 &self,
1234 issue_id: String,
1235 title: Option<String>,
1236 description: Option<String>,
1237 priority: Option<i32>,
1238 state: Option<String>,
1239 labels: Option<Vec<String>>,
1240 workspace_id: String,
1241 ) -> Result<(), RectilinearError> {
1242 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1243 let client =
1244 LinearClient::with_http_client(self.client().await.clone(), &api_key);
1245
1246 let state_id = if let Some(ref state_name) = state {
1247 if let Some(issue) = self.db.get_issue(&issue_id)? {
1249 Some(
1250 client
1251 .get_state_id(&issue.team_key, state_name)
1252 .await
1253 .map_err(|e| RectilinearError::Api {
1254 message: e.to_string(),
1255 })?,
1256 )
1257 } else {
1258 None
1259 }
1260 } else {
1261 None
1262 };
1263
1264 let label_ids =
1265 if let Some(ref label_names) = labels {
1266 Some(client.get_label_ids(label_names).await.map_err(|e| {
1267 RectilinearError::Api {
1268 message: e.to_string(),
1269 }
1270 })?)
1271 } else {
1272 None
1273 };
1274
1275 client
1276 .update_issue(
1277 &issue_id,
1278 crate::linear::UpdateIssueInput {
1279 title: title.as_deref(),
1280 description: description.as_deref(),
1281 priority,
1282 state_id: state_id.as_deref(),
1283 label_ids: label_ids.as_deref(),
1284 ..Default::default()
1285 },
1286 )
1287 .await
1288 .map_err(|e| RectilinearError::Api {
1289 message: e.to_string(),
1290 })?;
1291
1292 if let Ok((issue, relations, label_ids)) = client.fetch_single_issue(&issue_id).await {
1294 let _ = self.db.upsert_issue(&issue);
1295 let _ = self.db.upsert_relations(&issue.id, &relations);
1296 let _ = self.db.replace_issue_labels(&issue.id, &label_ids);
1297 }
1298
1299 Ok(())
1300 }
1301
1302 pub async fn create_issue(
1304 &self,
1305 input: RtCreateIssueInput,
1306 workspace_id: String,
1307 ) -> Result<RtCreateIssueResult, RectilinearError> {
1308 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1309 let client =
1310 LinearClient::with_http_client(self.client().await.clone(), &api_key);
1311
1312 let team_id = client
1313 .get_team_id(&input.team_key)
1314 .await
1315 .map_err(|e| RectilinearError::Api {
1316 message: e.to_string(),
1317 })?;
1318 let project_id = match (
1319 input.project_id.as_deref(),
1320 input.project_milestone_id.as_deref(),
1321 ) {
1322 (Some(project_id), _) => Some(project_id.to_string()),
1323 (None, Some(milestone_id)) => Some(
1324 client
1325 .fetch_project_milestone(milestone_id, &workspace_id)
1326 .await
1327 .map_err(api_error)?
1328 .project_id,
1329 ),
1330 (None, None) => None,
1331 };
1332
1333 let (id, identifier) = client
1334 .create_issue(crate::linear::CreateIssueInput {
1335 team_id: &team_id,
1336 title: &input.title,
1337 description: input.description.as_deref(),
1338 priority: input.priority,
1339 label_ids: &input.label_ids,
1340 assignee_id: None,
1341 parent_id: input.parent_id.as_deref(),
1342 project_id: project_id.as_deref(),
1343 project_milestone_id: input.project_milestone_id.as_deref(),
1344 })
1345 .await
1346 .map_err(|e| RectilinearError::Api {
1347 message: e.to_string(),
1348 })?;
1349
1350 Ok(RtCreateIssueResult { id, identifier })
1351 }
1352
1353 pub async fn add_comment(
1355 &self,
1356 issue_id: String,
1357 body: String,
1358 workspace_id: String,
1359 ) -> Result<(), RectilinearError> {
1360 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1361 let client =
1362 LinearClient::with_http_client(self.client().await.clone(), &api_key);
1363 client
1364 .add_comment(&issue_id, &body)
1365 .await
1366 .map_err(|e| RectilinearError::Api {
1367 message: e.to_string(),
1368 })
1369 }
1370
1371 pub async fn refresh_issue(
1374 &self,
1375 id_or_identifier: String,
1376 workspace_id: String,
1377 ) -> Result<Option<RtIssue>, RectilinearError> {
1378 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
1379 let client =
1380 LinearClient::with_http_client(self.client().await.clone(), &api_key);
1381
1382 let result = if id_or_identifier.contains('-')
1383 && id_or_identifier
1384 .chars()
1385 .last()
1386 .is_some_and(|c| c.is_ascii_digit())
1387 {
1388 client
1389 .fetch_issue_by_identifier(&id_or_identifier)
1390 .await
1391 .map_err(|e| RectilinearError::Api {
1392 message: e.to_string(),
1393 })?
1394 } else {
1395 Some(
1396 client
1397 .fetch_single_issue(&id_or_identifier)
1398 .await
1399 .map_err(|e| RectilinearError::Api {
1400 message: e.to_string(),
1401 })?,
1402 )
1403 };
1404
1405 if let Some((issue, relations, label_ids)) = result {
1406 self.db.upsert_issue(&issue)?;
1407 self.db.upsert_relations(&issue.id, &relations)?;
1408 self.db.replace_issue_labels(&issue.id, &label_ids)?;
1409 Ok(Some(RtIssue::from(issue)))
1410 } else {
1411 Ok(None)
1412 }
1413 }
1414
1415 pub async fn embed_issues(
1418 &self,
1419 team: Option<String>,
1420 limit: u32,
1421 workspace_id: String,
1422 ) -> Result<u64, RectilinearError> {
1423 let config = Config::load().unwrap_or_default();
1424 let embedder =
1425 self.make_embedder(&config)
1426 .await?
1427 .ok_or_else(|| {
1428 RectilinearError::Config {
1429 message:
1430 "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
1431 .into(),
1432 }
1433 })?;
1434
1435 let model_name = embedder.backend_name().to_string();
1436 let issues = self
1437 .db
1438 .get_issues_needing_embedding(team.as_deref(), false, &workspace_id)?;
1439
1440 let to_process = if limit > 0 {
1441 &issues[..std::cmp::min(issues.len(), limit as usize)]
1442 } else {
1443 &issues
1444 };
1445 let total = to_process.len() as u64;
1446
1447 self.set_sync_progress(Some(RtSyncProgress {
1448 phase: RtSyncPhase::GeneratingEmbeddings,
1449 completed: 0,
1450 total: Some(total),
1451 }));
1452
1453 const BATCH_SIZE: usize = 100;
1457
1458 struct IssueChunks {
1460 issue_id: String,
1461 chunks: Vec<String>,
1462 }
1463 let mut pending: Vec<IssueChunks> = Vec::new();
1464 for issue in to_process {
1465 if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
1466 if existing_model == model_name {
1467 continue;
1468 }
1469 }
1470 let chunks = crate::embedding::chunk_text(
1471 &issue.title,
1472 issue.description.as_deref().unwrap_or(""),
1473 512,
1474 64,
1475 );
1476 pending.push(IssueChunks {
1477 issue_id: issue.id.clone(),
1478 chunks,
1479 });
1480 }
1481
1482 let result: Result<u64, RectilinearError> = async {
1483 let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
1486 for (issue_idx, ic) in pending.iter().enumerate() {
1487 for (chunk_idx, text) in ic.chunks.iter().enumerate() {
1488 flat_chunks.push((issue_idx, chunk_idx, text.clone()));
1489 }
1490 }
1491
1492 let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
1494 for batch in flat_chunks.chunks(BATCH_SIZE) {
1495 let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
1496 let batch_embeddings =
1497 embedder
1498 .embed_batch(&texts)
1499 .await
1500 .map_err(|e| RectilinearError::Api {
1501 message: e.to_string(),
1502 })?;
1503 embeddings_flat.extend(batch_embeddings);
1504 }
1505
1506 let mut emb_offset = 0usize;
1508 let mut count = 0u64;
1509 for ic in &pending {
1510 let n = ic.chunks.len();
1511 let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
1512
1513 let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
1514 .chunks
1515 .iter()
1516 .zip(issue_embeddings.iter())
1517 .enumerate()
1518 .map(|(idx, (text, emb))| {
1519 (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
1520 })
1521 .collect();
1522
1523 self.db
1524 .upsert_chunks_with_model(&ic.issue_id, &chunk_data, &model_name)?;
1525 emb_offset += n;
1526 count += 1;
1527 self.set_sync_progress(Some(RtSyncProgress {
1528 phase: RtSyncPhase::GeneratingEmbeddings,
1529 completed: count,
1530 total: Some(total),
1531 }));
1532 }
1533
1534 Ok(count)
1535 }
1536 .await;
1537
1538 self.set_sync_progress(None);
1539 result
1540 }
1541}
1542
1543impl RectilinearEngine {
1546 async fn linear_client(
1547 &self,
1548 workspace_id: &str,
1549 ) -> Result<LinearClient, RectilinearError> {
1550 let api_key = self.linear_api_key_for_workspace(workspace_id)?;
1551 Ok(LinearClient::with_http_client(
1552 self.client().await.clone(),
1553 &api_key,
1554 ))
1555 }
1556
1557 async fn cache_project_milestone(
1558 &self,
1559 client: &LinearClient,
1560 milestone_id: &str,
1561 workspace_id: &str,
1562 ) -> Result<RtProjectMilestone, RectilinearError> {
1563 let milestone = client
1564 .fetch_project_milestone(milestone_id, workspace_id)
1565 .await
1566 .map_err(api_error)?;
1567 let project = client
1568 .fetch_project(&milestone.project_id, workspace_id)
1569 .await
1570 .map_err(api_error)?;
1571 self.db.upsert_project(&project)?;
1572 self.db.upsert_project_milestone(&milestone)?;
1573 Ok(milestone.into())
1574 }
1575
1576 fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
1577 *self.sync_progress.lock().unwrap() = progress;
1578 }
1579
1580 async fn make_embedder(
1581 &self,
1582 config: &Config,
1583 ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
1584 let key = self
1585 .gemini_api_key
1586 .as_deref()
1587 .or(config.embedding.gemini_api_key.as_deref());
1588
1589 if let Some(api_key) = key {
1590 Ok(Some(
1591 crate::embedding::Embedder::new_api_with_http_client(
1592 self.client().await.clone(),
1593 api_key,
1594 )
1595 .map_err(|e| RectilinearError::Config {
1596 message: e.to_string(),
1597 })?,
1598 ))
1599 } else {
1600 #[cfg(feature = "local-embeddings")]
1601 {
1602 let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
1603 message: e.to_string(),
1604 })?;
1605 Ok(Some(
1606 crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
1607 RectilinearError::Config {
1608 message: e.to_string(),
1609 }
1610 })?,
1611 ))
1612 }
1613 #[cfg(not(feature = "local-embeddings"))]
1614 {
1615 Ok(None)
1616 }
1617 }
1618 }
1619}
1620
1621fn api_error(error: anyhow::Error) -> RectilinearError {
1622 RectilinearError::Api {
1623 message: error.to_string(),
1624 }
1625}