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(uniffi::Record)]
224pub struct RtCreateIssueResult {
225 pub id: String,
226 pub identifier: String,
227}
228
229#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
230pub enum RtSyncPhase {
231 FetchingIssues,
232 GeneratingEmbeddings,
233}
234
235#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
236pub struct RtSyncProgress {
237 pub phase: RtSyncPhase,
238 pub completed: u64,
239 pub total: Option<u64>,
240}
241
242impl From<crate::db::TeamSummary> for RtTeamSummary {
243 fn from(t: crate::db::TeamSummary) -> Self {
244 Self {
245 key: t.key,
246 issue_count: t.issue_count as u64,
247 embedded_count: t.embedded_count as u64,
248 last_synced_at: t.last_synced_at,
249 }
250 }
251}
252
253impl From<RtSearchMode> for search::SearchMode {
254 fn from(mode: RtSearchMode) -> Self {
255 match mode {
256 RtSearchMode::Fts => search::SearchMode::Fts,
257 RtSearchMode::Vector => search::SearchMode::Vector,
258 RtSearchMode::Hybrid => search::SearchMode::Hybrid,
259 }
260 }
261}
262
263#[derive(uniffi::Object)]
266pub struct RectilinearEngine {
267 db: Database,
268 gemini_api_key: Option<String>,
269 sync_progress: Mutex<Option<RtSyncProgress>>,
270 http_client: OnceCell<reqwest::Client>,
273}
274
275impl RectilinearEngine {
276 async fn client(&self) -> &reqwest::Client {
280 self.http_client
281 .get_or_init(|| async { reqwest::Client::new() })
282 .await
283 }
284}
285
286#[uniffi::export(async_runtime = "tokio")]
287impl RectilinearEngine {
288 #[uniffi::constructor]
291 pub fn new(
292 db_path: String,
293 gemini_api_key: Option<String>,
294 ) -> Result<Self, RectilinearError> {
295 let path = Path::new(&db_path);
296 if let Some(parent) = path.parent() {
297 std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
298 message: format!("Failed to create database directory: {e}"),
299 })?;
300 }
301
302 let db = Database::open(path)?;
303
304 Ok(Self {
305 db,
306 gemini_api_key,
307 sync_progress: Mutex::new(None),
308 http_client: OnceCell::new(),
309 })
310 }
311
312 pub fn linear_api_key_for_workspace(
314 &self,
315 workspace_id: &str,
316 ) -> Result<String, RectilinearError> {
317 let config = Config::load().map_err(|e| RectilinearError::Config {
318 message: e.to_string(),
319 })?;
320 config
321 .workspace_api_key(workspace_id)
322 .map_err(|e| RectilinearError::Config {
323 message: e.to_string(),
324 })
325 }
326
327 pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
329 let config = Config::load().map_err(|e| RectilinearError::Config {
330 message: e.to_string(),
331 })?;
332 Ok(config.workspace_names())
333 }
334
335 pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
337 let config = Config::load().map_err(|e| RectilinearError::Config {
338 message: e.to_string(),
339 })?;
340 config
341 .resolve_active_workspace()
342 .map_err(|e| RectilinearError::Config {
343 message: e.to_string(),
344 })
345 }
346
347 pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
351 Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
352 }
353
354 pub fn get_triage_queue(
356 &self,
357 team: Option<String>,
358 include_completed: bool,
359 workspace_id: String,
360 ) -> Result<Vec<RtIssue>, RectilinearError> {
361 let issues =
362 self.db
363 .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
364 Ok(issues.into_iter().map(RtIssue::from).collect())
365 }
366
367 pub fn search_fts(
369 &self,
370 query: String,
371 limit: u32,
372 workspace_id: String,
373 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
374 let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
375 Ok(results
376 .into_iter()
377 .map(|fts| RtSearchResult {
378 issue_id: fts.issue_id,
379 identifier: fts.identifier,
380 title: fts.title,
381 state_name: fts.state_name,
382 priority: fts.priority,
383 score: fts.bm25_score,
384 similarity: None,
385 })
386 .collect())
387 }
388
389 pub fn count_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
391 Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
392 }
393
394 pub fn count_embedded_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
396 Ok(self.db.count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
397 }
398
399 pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
401 self.sync_progress.lock().unwrap().clone()
402 }
403
404 pub fn get_field_completeness(
406 &self,
407 team: Option<String>,
408 workspace_id: String,
409 ) -> Result<RtFieldCompleteness, RectilinearError> {
410 let (total, desc, pri, labels, proj) =
411 self.db.get_field_completeness(team.as_deref(), &workspace_id)?;
412 Ok(RtFieldCompleteness {
413 total: total as u64,
414 with_description: desc as u64,
415 with_priority: pri as u64,
416 with_labels: labels as u64,
417 with_project: proj as u64,
418 })
419 }
420
421 pub fn list_all_issues(
423 &self,
424 team: Option<String>,
425 filter: Option<String>,
426 limit: u32,
427 offset: u32,
428 workspace_id: String,
429 ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
430 let issues = self.db.list_all_issues(
431 team.as_deref(),
432 filter.as_deref(),
433 limit as usize,
434 offset as usize,
435 &workspace_id,
436 )?;
437 Ok(issues.into_iter().map(RtIssueSummary::from).collect())
438 }
439
440 pub fn list_synced_teams(&self, workspace_id: String) -> Result<Vec<RtTeamSummary>, RectilinearError> {
442 Ok(self
443 .db
444 .list_synced_teams(&workspace_id)?
445 .into_iter()
446 .map(RtTeamSummary::from)
447 .collect())
448 }
449
450 pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
452 Ok(self
453 .db
454 .get_relations_enriched(&issue_id)?
455 .into_iter()
456 .map(RtRelation::from)
457 .collect())
458 }
459
460 pub fn get_active_issues(
462 &self,
463 team: String,
464 state_types: Vec<String>,
465 workspace_id: String,
466 ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
467 let issues = self
468 .db
469 .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
470 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
471 let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
472
473 let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
475 std::collections::HashMap::new();
476 for b in blockers {
477 let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
478 blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
479 identifier: b.identifier,
480 title: b.title,
481 state_name: b.state_name,
482 is_terminal,
483 });
484 }
485
486 Ok(issues
487 .into_iter()
488 .map(|issue| {
489 let labels: Vec<String> =
490 serde_json::from_str(&issue.labels_json).unwrap_or_default();
491 let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
492 RtIssueEnriched {
493 id: issue.id,
494 identifier: issue.identifier,
495 team_key: issue.team_key,
496 title: issue.title,
497 description: issue.description,
498 state_name: issue.state_name,
499 state_type: issue.state_type,
500 priority: issue.priority,
501 assignee_name: issue.assignee_name,
502 project_name: issue.project_name,
503 labels,
504 created_at: issue.created_at,
505 updated_at: issue.updated_at,
506 url: issue.url,
507 branch_name: issue.branch_name,
508 blocked_by,
509 }
510 })
511 .collect())
512 }
513
514 pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
518 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
519 let client =
520 LinearClient::with_http_client(self.client().await.clone(), &api_key);
521 let teams = client
522 .list_teams()
523 .await
524 .map_err(|e| RectilinearError::Api {
525 message: e.to_string(),
526 })?;
527 Ok(teams
528 .into_iter()
529 .map(|t| RtTeam {
530 id: t.id,
531 key: t.key,
532 name: t.name,
533 })
534 .collect())
535 }
536
537 pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
539 let api_key = self
540 .gemini_api_key
541 .as_deref()
542 .ok_or_else(|| RectilinearError::Config {
543 message: "Gemini API key not configured".into(),
544 })?;
545
546 crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
547 .map_err(|e| RectilinearError::Config {
548 message: e.to_string(),
549 })?
550 .test_api_key()
551 .await
552 .map_err(|e| RectilinearError::Api {
553 message: e.to_string(),
554 })
555 }
556
557 pub async fn sync_team(&self, team_key: String, full: bool, workspace_id: String) -> Result<u64, RectilinearError> {
559 self.set_sync_progress(Some(RtSyncProgress {
560 phase: RtSyncPhase::FetchingIssues,
561 completed: 0,
562 total: None,
563 }));
564
565 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
566 let client =
567 LinearClient::with_http_client(self.client().await.clone(), &api_key);
568 let progress_state = &self.sync_progress;
569 let progress = move |count: usize| {
570 *progress_state.lock().unwrap() = Some(RtSyncProgress {
571 phase: RtSyncPhase::FetchingIssues,
572 completed: count as u64,
573 total: None,
574 });
575 };
576 let result = client
577 .sync_team(&self.db, &team_key, &workspace_id, full, false, Some(&progress))
578 .await
579 .map_err(|e| RectilinearError::Api {
580 message: e.to_string(),
581 });
582 self.set_sync_progress(None);
583 result.map(|count| count as u64)
584 }
585
586 pub async fn search_hybrid(
588 &self,
589 query: String,
590 team: Option<String>,
591 limit: u32,
592 workspace_id: String,
593 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
594 let config = Config::load().unwrap_or_default();
595 let embedder = self.make_embedder(&config).await?;
596
597 let results = search::search(
598 &self.db,
599 search::SearchParams {
600 query: &query,
601 mode: search::SearchMode::Hybrid,
602 team_key: team.as_deref(),
603 state_filter: None,
604 label_ids: None,
605 limit: limit as usize,
606 embedder: embedder.as_ref(),
607 rrf_k: config.search.rrf_k,
608 workspace_id: &workspace_id,
609 },
610 )
611 .await?;
612
613 Ok(results.into_iter().map(RtSearchResult::from).collect())
614 }
615
616 pub async fn find_duplicates(
618 &self,
619 text: String,
620 team: Option<String>,
621 threshold: f32,
622 workspace_id: String,
623 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
624 let config = Config::load().unwrap_or_default();
625 let embedder =
626 self.make_embedder(&config)
627 .await?
628 .ok_or_else(|| RectilinearError::Config {
629 message:
630 "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
631 .into(),
632 })?;
633
634 let results = search::find_duplicates(
635 &self.db,
636 &text,
637 team.as_deref(),
638 threshold,
639 10,
640 &embedder,
641 config.search.rrf_k,
642 &workspace_id,
643 )
644 .await?;
645
646 Ok(results.into_iter().map(RtSearchResult::from).collect())
647 }
648
649 pub async fn save_issue(
651 &self,
652 issue_id: String,
653 title: Option<String>,
654 description: Option<String>,
655 priority: Option<i32>,
656 state: Option<String>,
657 labels: Option<Vec<String>>,
658 workspace_id: String,
659 ) -> Result<(), RectilinearError> {
660 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
661 let client =
662 LinearClient::with_http_client(self.client().await.clone(), &api_key);
663
664 let state_id = if let Some(ref state_name) = state {
665 if let Some(issue) = self.db.get_issue(&issue_id)? {
667 Some(
668 client
669 .get_state_id(&issue.team_key, state_name)
670 .await
671 .map_err(|e| RectilinearError::Api {
672 message: e.to_string(),
673 })?,
674 )
675 } else {
676 None
677 }
678 } else {
679 None
680 };
681
682 let label_ids =
683 if let Some(ref label_names) = labels {
684 Some(client.get_label_ids(label_names).await.map_err(|e| {
685 RectilinearError::Api {
686 message: e.to_string(),
687 }
688 })?)
689 } else {
690 None
691 };
692
693 client
694 .update_issue(
695 &issue_id,
696 title.as_deref(),
697 description.as_deref(),
698 priority,
699 state_id.as_deref(),
700 label_ids.as_deref(),
701 None,
702 None, )
704 .await
705 .map_err(|e| RectilinearError::Api {
706 message: e.to_string(),
707 })?;
708
709 if let Ok((issue, relations, label_ids)) = client.fetch_single_issue(&issue_id).await {
711 let _ = self.db.upsert_issue(&issue);
712 let _ = self.db.upsert_relations(&issue.id, &relations);
713 let _ = self.db.replace_issue_labels(&issue.id, &label_ids);
714 }
715
716 Ok(())
717 }
718
719 pub async fn create_issue(
721 &self,
722 team_key: String,
723 title: String,
724 description: Option<String>,
725 priority: Option<i32>,
726 label_ids: Vec<String>,
727 parent_id: Option<String>,
728 workspace_id: String,
729 ) -> Result<RtCreateIssueResult, RectilinearError> {
730 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
731 let client =
732 LinearClient::with_http_client(self.client().await.clone(), &api_key);
733
734 let team_id = client
735 .get_team_id(&team_key)
736 .await
737 .map_err(|e| RectilinearError::Api {
738 message: e.to_string(),
739 })?;
740
741 let (id, identifier) = client
742 .create_issue(
743 &team_id,
744 &title,
745 description.as_deref(),
746 priority,
747 &label_ids,
748 None, parent_id.as_deref(),
750 )
751 .await
752 .map_err(|e| RectilinearError::Api {
753 message: e.to_string(),
754 })?;
755
756 Ok(RtCreateIssueResult { id, identifier })
757 }
758
759 pub async fn add_comment(
761 &self,
762 issue_id: String,
763 body: String,
764 workspace_id: String,
765 ) -> Result<(), RectilinearError> {
766 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
767 let client =
768 LinearClient::with_http_client(self.client().await.clone(), &api_key);
769 client
770 .add_comment(&issue_id, &body)
771 .await
772 .map_err(|e| RectilinearError::Api {
773 message: e.to_string(),
774 })
775 }
776
777 pub async fn refresh_issue(
780 &self,
781 id_or_identifier: String,
782 workspace_id: String,
783 ) -> Result<Option<RtIssue>, RectilinearError> {
784 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
785 let client =
786 LinearClient::with_http_client(self.client().await.clone(), &api_key);
787
788 let result = if id_or_identifier.contains('-')
789 && id_or_identifier
790 .chars()
791 .last()
792 .is_some_and(|c| c.is_ascii_digit())
793 {
794 client
795 .fetch_issue_by_identifier(&id_or_identifier)
796 .await
797 .map_err(|e| RectilinearError::Api {
798 message: e.to_string(),
799 })?
800 } else {
801 Some(
802 client
803 .fetch_single_issue(&id_or_identifier)
804 .await
805 .map_err(|e| RectilinearError::Api {
806 message: e.to_string(),
807 })?,
808 )
809 };
810
811 if let Some((issue, relations, label_ids)) = result {
812 self.db.upsert_issue(&issue)?;
813 self.db.upsert_relations(&issue.id, &relations)?;
814 self.db.replace_issue_labels(&issue.id, &label_ids)?;
815 Ok(Some(RtIssue::from(issue)))
816 } else {
817 Ok(None)
818 }
819 }
820
821 pub async fn embed_issues(
824 &self,
825 team: Option<String>,
826 limit: u32,
827 workspace_id: String,
828 ) -> Result<u64, RectilinearError> {
829 let config = Config::load().unwrap_or_default();
830 let embedder =
831 self.make_embedder(&config)
832 .await?
833 .ok_or_else(|| {
834 RectilinearError::Config {
835 message:
836 "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
837 .into(),
838 }
839 })?;
840
841 let model_name = embedder.backend_name().to_string();
842 let issues = self
843 .db
844 .get_issues_needing_embedding(team.as_deref(), false, &workspace_id)?;
845
846 let to_process = if limit > 0 {
847 &issues[..std::cmp::min(issues.len(), limit as usize)]
848 } else {
849 &issues
850 };
851 let total = to_process.len() as u64;
852
853 self.set_sync_progress(Some(RtSyncProgress {
854 phase: RtSyncPhase::GeneratingEmbeddings,
855 completed: 0,
856 total: Some(total),
857 }));
858
859 const BATCH_SIZE: usize = 100;
863
864 struct IssueChunks {
866 issue_id: String,
867 chunks: Vec<String>,
868 }
869 let mut pending: Vec<IssueChunks> = Vec::new();
870 for issue in to_process {
871 if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
872 if existing_model == model_name {
873 continue;
874 }
875 }
876 let chunks = crate::embedding::chunk_text(
877 &issue.title,
878 issue.description.as_deref().unwrap_or(""),
879 512,
880 64,
881 );
882 pending.push(IssueChunks {
883 issue_id: issue.id.clone(),
884 chunks,
885 });
886 }
887
888 let result: Result<u64, RectilinearError> = async {
889 let mut flat_chunks: Vec<(usize, usize, String)> = Vec::new();
892 for (issue_idx, ic) in pending.iter().enumerate() {
893 for (chunk_idx, text) in ic.chunks.iter().enumerate() {
894 flat_chunks.push((issue_idx, chunk_idx, text.clone()));
895 }
896 }
897
898 let mut embeddings_flat: Vec<Vec<f32>> = Vec::with_capacity(flat_chunks.len());
900 for batch in flat_chunks.chunks(BATCH_SIZE) {
901 let texts: Vec<String> = batch.iter().map(|(_, _, t)| t.clone()).collect();
902 let batch_embeddings =
903 embedder
904 .embed_batch(&texts)
905 .await
906 .map_err(|e| RectilinearError::Api {
907 message: e.to_string(),
908 })?;
909 embeddings_flat.extend(batch_embeddings);
910 }
911
912 let mut emb_offset = 0usize;
914 let mut count = 0u64;
915 for ic in &pending {
916 let n = ic.chunks.len();
917 let issue_embeddings = &embeddings_flat[emb_offset..emb_offset + n];
918
919 let chunk_data: Vec<(usize, String, Vec<u8>)> = ic
920 .chunks
921 .iter()
922 .zip(issue_embeddings.iter())
923 .enumerate()
924 .map(|(idx, (text, emb))| {
925 (idx, text.clone(), crate::embedding::embedding_to_bytes(emb))
926 })
927 .collect();
928
929 self.db
930 .upsert_chunks_with_model(&ic.issue_id, &chunk_data, &model_name)?;
931 emb_offset += n;
932 count += 1;
933 self.set_sync_progress(Some(RtSyncProgress {
934 phase: RtSyncPhase::GeneratingEmbeddings,
935 completed: count,
936 total: Some(total),
937 }));
938 }
939
940 Ok(count)
941 }
942 .await;
943
944 self.set_sync_progress(None);
945 result
946 }
947}
948
949impl RectilinearEngine {
952 fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
953 *self.sync_progress.lock().unwrap() = progress;
954 }
955
956 async fn make_embedder(
957 &self,
958 config: &Config,
959 ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
960 let key = self
961 .gemini_api_key
962 .as_deref()
963 .or(config.embedding.gemini_api_key.as_deref());
964
965 if let Some(api_key) = key {
966 Ok(Some(
967 crate::embedding::Embedder::new_api_with_http_client(
968 self.client().await.clone(),
969 api_key,
970 )
971 .map_err(|e| RectilinearError::Config {
972 message: e.to_string(),
973 })?,
974 ))
975 } else {
976 #[cfg(feature = "local-embeddings")]
977 {
978 let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
979 message: e.to_string(),
980 })?;
981 Ok(Some(
982 crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
983 RectilinearError::Config {
984 message: e.to_string(),
985 }
986 })?,
987 ))
988 }
989 #[cfg(not(feature = "local-embeddings"))]
990 {
991 Ok(None)
992 }
993 }
994 }
995}