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