1use crate::config::Config;
8use crate::db::Database;
9use crate::linear::LinearClient;
10use crate::search;
11use std::path::Path;
12use std::sync::Mutex;
13use tokio::sync::OnceCell;
14
15#[derive(Debug, thiserror::Error, uniffi::Error)]
18pub enum RectilinearError {
19 #[error("Database error: {message}")]
20 Database { message: String },
21 #[error("API error: {message}")]
22 Api { message: String },
23 #[error("Config error: {message}")]
24 Config { message: String },
25 #[error("Not found: {key}")]
26 NotFound { key: String },
27}
28
29impl From<anyhow::Error> for RectilinearError {
30 fn from(err: anyhow::Error) -> Self {
31 RectilinearError::Database {
32 message: err.to_string(),
33 }
34 }
35}
36
37#[derive(uniffi::Record)]
40pub struct RtIssue {
41 pub id: String,
42 pub identifier: String,
43 pub team_key: String,
44 pub title: String,
45 pub description: Option<String>,
46 pub state_name: String,
47 pub state_type: String,
48 pub priority: i32,
49 pub assignee_name: Option<String>,
50 pub project_name: Option<String>,
51 pub labels: Vec<String>,
52 pub created_at: String,
53 pub updated_at: String,
54 pub url: String,
55 pub branch_name: Option<String>,
56}
57
58impl From<crate::db::Issue> for RtIssue {
59 fn from(issue: crate::db::Issue) -> Self {
60 let labels: Vec<String> = serde_json::from_str(&issue.labels_json).unwrap_or_default();
61 Self {
62 id: issue.id,
63 identifier: issue.identifier,
64 team_key: issue.team_key,
65 title: issue.title,
66 description: issue.description,
67 state_name: issue.state_name,
68 state_type: issue.state_type,
69 priority: issue.priority,
70 assignee_name: issue.assignee_name,
71 project_name: issue.project_name,
72 labels,
73 created_at: issue.created_at,
74 updated_at: issue.updated_at,
75 url: issue.url,
76 branch_name: issue.branch_name,
77 }
78 }
79}
80
81#[derive(uniffi::Record)]
82pub struct RtSearchResult {
83 pub issue_id: String,
84 pub identifier: String,
85 pub title: String,
86 pub state_name: String,
87 pub priority: i32,
88 pub score: f64,
89 pub similarity: Option<f32>,
90}
91
92impl From<search::SearchResult> for RtSearchResult {
93 fn from(sr: search::SearchResult) -> Self {
94 Self {
95 issue_id: sr.issue_id,
96 identifier: sr.identifier,
97 title: sr.title,
98 state_name: sr.state_name,
99 priority: sr.priority,
100 score: sr.score,
101 similarity: sr.similarity,
102 }
103 }
104}
105
106#[derive(uniffi::Record)]
107pub struct RtRelation {
108 pub relation_type: String,
109 pub issue_identifier: String,
110 pub issue_title: String,
111 pub issue_state: String,
112 pub issue_url: String,
113}
114
115impl From<crate::db::EnrichedRelation> for RtRelation {
116 fn from(rel: crate::db::EnrichedRelation) -> Self {
117 Self {
118 relation_type: rel.relation_type,
119 issue_identifier: rel.issue_identifier,
120 issue_title: rel.issue_title,
121 issue_state: rel.issue_state,
122 issue_url: rel.issue_url,
123 }
124 }
125}
126
127#[derive(uniffi::Record)]
128pub struct RtBlocker {
129 pub identifier: String,
130 pub title: String,
131 pub state_name: String,
132 pub is_terminal: bool,
133}
134
135#[derive(uniffi::Record)]
136pub struct RtIssueEnriched {
137 pub id: String,
138 pub identifier: String,
139 pub team_key: String,
140 pub title: String,
141 pub description: Option<String>,
142 pub state_name: String,
143 pub state_type: String,
144 pub priority: i32,
145 pub assignee_name: Option<String>,
146 pub project_name: Option<String>,
147 pub labels: Vec<String>,
148 pub created_at: String,
149 pub updated_at: String,
150 pub url: String,
151 pub branch_name: Option<String>,
152 pub blocked_by: Vec<RtBlocker>,
153}
154
155#[derive(uniffi::Record)]
156pub struct RtTeam {
157 pub id: String,
158 pub key: String,
159 pub name: String,
160}
161
162#[derive(uniffi::Enum)]
163pub enum RtSearchMode {
164 Fts,
165 Vector,
166 Hybrid,
167}
168
169#[derive(uniffi::Record)]
170pub struct RtFieldCompleteness {
171 pub total: u64,
172 pub with_description: u64,
173 pub with_priority: u64,
174 pub with_labels: u64,
175 pub with_project: u64,
176}
177
178#[derive(uniffi::Record)]
179pub struct RtIssueSummary {
180 pub id: String,
181 pub identifier: String,
182 pub team_key: String,
183 pub title: String,
184 pub state_name: String,
185 pub state_type: String,
186 pub priority: i32,
187 pub project_name: Option<String>,
188 pub labels: Vec<String>,
189 pub updated_at: String,
190 pub url: String,
191 pub has_description: bool,
192 pub has_embedding: bool,
193}
194
195impl From<crate::db::IssueSummary> for RtIssueSummary {
196 fn from(s: crate::db::IssueSummary) -> Self {
197 Self {
198 id: s.id,
199 identifier: s.identifier,
200 team_key: s.team_key,
201 title: s.title,
202 state_name: s.state_name,
203 state_type: s.state_type,
204 priority: s.priority,
205 project_name: s.project_name,
206 labels: s.labels,
207 updated_at: s.updated_at,
208 url: s.url,
209 has_description: s.has_description,
210 has_embedding: s.has_embedding,
211 }
212 }
213}
214
215#[derive(uniffi::Record)]
216pub struct RtTeamSummary {
217 pub key: String,
218 pub issue_count: u64,
219 pub embedded_count: u64,
220 pub last_synced_at: Option<String>,
221}
222
223#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
224pub enum RtSyncPhase {
225 FetchingIssues,
226 GeneratingEmbeddings,
227}
228
229#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
230pub struct RtSyncProgress {
231 pub phase: RtSyncPhase,
232 pub completed: u64,
233 pub total: Option<u64>,
234}
235
236impl From<crate::db::TeamSummary> for RtTeamSummary {
237 fn from(t: crate::db::TeamSummary) -> Self {
238 Self {
239 key: t.key,
240 issue_count: t.issue_count as u64,
241 embedded_count: t.embedded_count as u64,
242 last_synced_at: t.last_synced_at,
243 }
244 }
245}
246
247impl From<RtSearchMode> for search::SearchMode {
248 fn from(mode: RtSearchMode) -> Self {
249 match mode {
250 RtSearchMode::Fts => search::SearchMode::Fts,
251 RtSearchMode::Vector => search::SearchMode::Vector,
252 RtSearchMode::Hybrid => search::SearchMode::Hybrid,
253 }
254 }
255}
256
257#[derive(uniffi::Object)]
260pub struct RectilinearEngine {
261 db: Database,
262 gemini_api_key: Option<String>,
263 sync_progress: Mutex<Option<RtSyncProgress>>,
264 http_client: OnceCell<reqwest::Client>,
267}
268
269impl RectilinearEngine {
270 async fn client(&self) -> &reqwest::Client {
274 self.http_client
275 .get_or_init(|| async { reqwest::Client::new() })
276 .await
277 }
278}
279
280#[uniffi::export(async_runtime = "tokio")]
281impl RectilinearEngine {
282 #[uniffi::constructor]
285 pub fn new(
286 db_path: String,
287 gemini_api_key: Option<String>,
288 ) -> Result<Self, RectilinearError> {
289 let path = Path::new(&db_path);
290 if let Some(parent) = path.parent() {
291 std::fs::create_dir_all(parent).map_err(|e| RectilinearError::Config {
292 message: format!("Failed to create database directory: {e}"),
293 })?;
294 }
295
296 let db = Database::open(path)?;
297
298 Ok(Self {
299 db,
300 gemini_api_key,
301 sync_progress: Mutex::new(None),
302 http_client: OnceCell::new(),
303 })
304 }
305
306 pub fn linear_api_key_for_workspace(
308 &self,
309 workspace_id: &str,
310 ) -> Result<String, RectilinearError> {
311 let config = Config::load().map_err(|e| RectilinearError::Config {
312 message: e.to_string(),
313 })?;
314 config
315 .workspace_api_key(workspace_id)
316 .map_err(|e| RectilinearError::Config {
317 message: e.to_string(),
318 })
319 }
320
321 pub fn list_workspaces(&self) -> Result<Vec<String>, RectilinearError> {
323 let config = Config::load().map_err(|e| RectilinearError::Config {
324 message: e.to_string(),
325 })?;
326 Ok(config.workspace_names())
327 }
328
329 pub fn get_active_workspace(&self) -> Result<String, RectilinearError> {
331 let config = Config::load().map_err(|e| RectilinearError::Config {
332 message: e.to_string(),
333 })?;
334 config
335 .resolve_active_workspace()
336 .map_err(|e| RectilinearError::Config {
337 message: e.to_string(),
338 })
339 }
340
341 pub fn get_issue(&self, id_or_identifier: String) -> Result<Option<RtIssue>, RectilinearError> {
345 Ok(self.db.get_issue(&id_or_identifier)?.map(RtIssue::from))
346 }
347
348 pub fn get_triage_queue(
350 &self,
351 team: Option<String>,
352 include_completed: bool,
353 workspace_id: String,
354 ) -> Result<Vec<RtIssue>, RectilinearError> {
355 let issues =
356 self.db
357 .get_unprioritized_issues(team.as_deref(), include_completed, &workspace_id)?;
358 Ok(issues.into_iter().map(RtIssue::from).collect())
359 }
360
361 pub fn search_fts(
363 &self,
364 query: String,
365 limit: u32,
366 workspace_id: String,
367 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
368 let results = self.db.fts_search(&query, limit as usize, &workspace_id)?;
369 Ok(results
370 .into_iter()
371 .map(|fts| RtSearchResult {
372 issue_id: fts.issue_id,
373 identifier: fts.identifier,
374 title: fts.title,
375 state_name: fts.state_name,
376 priority: fts.priority,
377 score: fts.bm25_score,
378 similarity: None,
379 })
380 .collect())
381 }
382
383 pub fn count_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
385 Ok(self.db.count_issues(team.as_deref(), &workspace_id)? as u64)
386 }
387
388 pub fn count_embedded_issues(&self, team: Option<String>, workspace_id: String) -> Result<u64, RectilinearError> {
390 Ok(self.db.count_embedded_issues(team.as_deref(), &workspace_id)? as u64)
391 }
392
393 pub fn get_sync_progress(&self) -> Option<RtSyncProgress> {
395 self.sync_progress.lock().unwrap().clone()
396 }
397
398 pub fn get_field_completeness(
400 &self,
401 team: Option<String>,
402 workspace_id: String,
403 ) -> Result<RtFieldCompleteness, RectilinearError> {
404 let (total, desc, pri, labels, proj) =
405 self.db.get_field_completeness(team.as_deref(), &workspace_id)?;
406 Ok(RtFieldCompleteness {
407 total: total as u64,
408 with_description: desc as u64,
409 with_priority: pri as u64,
410 with_labels: labels as u64,
411 with_project: proj as u64,
412 })
413 }
414
415 pub fn list_all_issues(
417 &self,
418 team: Option<String>,
419 filter: Option<String>,
420 limit: u32,
421 offset: u32,
422 workspace_id: String,
423 ) -> Result<Vec<RtIssueSummary>, RectilinearError> {
424 let issues = self.db.list_all_issues(
425 team.as_deref(),
426 filter.as_deref(),
427 limit as usize,
428 offset as usize,
429 &workspace_id,
430 )?;
431 Ok(issues.into_iter().map(RtIssueSummary::from).collect())
432 }
433
434 pub fn list_synced_teams(&self, workspace_id: String) -> Result<Vec<RtTeamSummary>, RectilinearError> {
436 Ok(self
437 .db
438 .list_synced_teams(&workspace_id)?
439 .into_iter()
440 .map(RtTeamSummary::from)
441 .collect())
442 }
443
444 pub fn get_relations(&self, issue_id: String) -> Result<Vec<RtRelation>, RectilinearError> {
446 Ok(self
447 .db
448 .get_relations_enriched(&issue_id)?
449 .into_iter()
450 .map(RtRelation::from)
451 .collect())
452 }
453
454 pub fn get_active_issues(
456 &self,
457 team: String,
458 state_types: Vec<String>,
459 workspace_id: String,
460 ) -> Result<Vec<RtIssueEnriched>, RectilinearError> {
461 let issues = self
462 .db
463 .get_issues_by_state_types(&team, &state_types, &workspace_id)?;
464 let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
465 let blockers = self.db.get_blockers_for_issues(&issue_ids)?;
466
467 let mut blocker_map: std::collections::HashMap<String, Vec<RtBlocker>> =
469 std::collections::HashMap::new();
470 for b in blockers {
471 let is_terminal = matches!(b.state_type.as_str(), "completed" | "canceled");
472 blocker_map.entry(b.issue_id).or_default().push(RtBlocker {
473 identifier: b.identifier,
474 title: b.title,
475 state_name: b.state_name,
476 is_terminal,
477 });
478 }
479
480 Ok(issues
481 .into_iter()
482 .map(|issue| {
483 let labels: Vec<String> =
484 serde_json::from_str(&issue.labels_json).unwrap_or_default();
485 let blocked_by = blocker_map.remove(&issue.id).unwrap_or_default();
486 RtIssueEnriched {
487 id: issue.id,
488 identifier: issue.identifier,
489 team_key: issue.team_key,
490 title: issue.title,
491 description: issue.description,
492 state_name: issue.state_name,
493 state_type: issue.state_type,
494 priority: issue.priority,
495 assignee_name: issue.assignee_name,
496 project_name: issue.project_name,
497 labels,
498 created_at: issue.created_at,
499 updated_at: issue.updated_at,
500 url: issue.url,
501 branch_name: issue.branch_name,
502 blocked_by,
503 }
504 })
505 .collect())
506 }
507
508 pub async fn list_teams(&self, workspace_id: String) -> Result<Vec<RtTeam>, RectilinearError> {
512 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
513 let client =
514 LinearClient::with_http_client(self.client().await.clone(), &api_key);
515 let teams = client
516 .list_teams()
517 .await
518 .map_err(|e| RectilinearError::Api {
519 message: e.to_string(),
520 })?;
521 Ok(teams
522 .into_iter()
523 .map(|t| RtTeam {
524 id: t.id,
525 key: t.key,
526 name: t.name,
527 })
528 .collect())
529 }
530
531 pub async fn test_gemini_api_key(&self) -> Result<(), RectilinearError> {
533 let api_key = self
534 .gemini_api_key
535 .as_deref()
536 .ok_or_else(|| RectilinearError::Config {
537 message: "Gemini API key not configured".into(),
538 })?;
539
540 crate::embedding::Embedder::new_api_with_http_client(self.client().await.clone(), api_key)
541 .map_err(|e| RectilinearError::Config {
542 message: e.to_string(),
543 })?
544 .test_api_key()
545 .await
546 .map_err(|e| RectilinearError::Api {
547 message: e.to_string(),
548 })
549 }
550
551 pub async fn sync_team(&self, team_key: String, full: bool, workspace_id: String) -> Result<u64, RectilinearError> {
553 self.set_sync_progress(Some(RtSyncProgress {
554 phase: RtSyncPhase::FetchingIssues,
555 completed: 0,
556 total: None,
557 }));
558
559 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
560 let client =
561 LinearClient::with_http_client(self.client().await.clone(), &api_key);
562 let progress_state = &self.sync_progress;
563 let progress = move |count: usize| {
564 *progress_state.lock().unwrap() = Some(RtSyncProgress {
565 phase: RtSyncPhase::FetchingIssues,
566 completed: count as u64,
567 total: None,
568 });
569 };
570 let result = client
571 .sync_team(&self.db, &team_key, &workspace_id, full, false, Some(&progress))
572 .await
573 .map_err(|e| RectilinearError::Api {
574 message: e.to_string(),
575 });
576 self.set_sync_progress(None);
577 result.map(|count| count as u64)
578 }
579
580 pub async fn search_hybrid(
582 &self,
583 query: String,
584 team: Option<String>,
585 limit: u32,
586 workspace_id: String,
587 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
588 let config = Config::load().unwrap_or_default();
589 let embedder = self.make_embedder(&config).await?;
590
591 let results = search::search(
592 &self.db,
593 search::SearchParams {
594 query: &query,
595 mode: search::SearchMode::Hybrid,
596 team_key: team.as_deref(),
597 state_filter: None,
598 limit: limit as usize,
599 embedder: embedder.as_ref(),
600 rrf_k: config.search.rrf_k,
601 workspace_id: &workspace_id,
602 },
603 )
604 .await?;
605
606 Ok(results.into_iter().map(RtSearchResult::from).collect())
607 }
608
609 pub async fn find_duplicates(
611 &self,
612 text: String,
613 team: Option<String>,
614 threshold: f32,
615 workspace_id: String,
616 ) -> Result<Vec<RtSearchResult>, RectilinearError> {
617 let config = Config::load().unwrap_or_default();
618 let embedder =
619 self.make_embedder(&config)
620 .await?
621 .ok_or_else(|| RectilinearError::Config {
622 message:
623 "Embedder not available — set GEMINI_API_KEY or enable local embeddings"
624 .into(),
625 })?;
626
627 let results = search::find_duplicates(
628 &self.db,
629 &text,
630 team.as_deref(),
631 threshold,
632 10,
633 &embedder,
634 config.search.rrf_k,
635 &workspace_id,
636 )
637 .await?;
638
639 Ok(results.into_iter().map(RtSearchResult::from).collect())
640 }
641
642 pub async fn save_issue(
644 &self,
645 issue_id: String,
646 title: Option<String>,
647 description: Option<String>,
648 priority: Option<i32>,
649 state: Option<String>,
650 labels: Option<Vec<String>>,
651 workspace_id: String,
652 ) -> Result<(), RectilinearError> {
653 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
654 let client =
655 LinearClient::with_http_client(self.client().await.clone(), &api_key);
656
657 let state_id = if let Some(ref state_name) = state {
658 if let Some(issue) = self.db.get_issue(&issue_id)? {
660 Some(
661 client
662 .get_state_id(&issue.team_key, state_name)
663 .await
664 .map_err(|e| RectilinearError::Api {
665 message: e.to_string(),
666 })?,
667 )
668 } else {
669 None
670 }
671 } else {
672 None
673 };
674
675 let label_ids =
676 if let Some(ref label_names) = labels {
677 Some(client.get_label_ids(label_names).await.map_err(|e| {
678 RectilinearError::Api {
679 message: e.to_string(),
680 }
681 })?)
682 } else {
683 None
684 };
685
686 client
687 .update_issue(
688 &issue_id,
689 title.as_deref(),
690 description.as_deref(),
691 priority,
692 state_id.as_deref(),
693 label_ids.as_deref(),
694 None,
695 )
696 .await
697 .map_err(|e| RectilinearError::Api {
698 message: e.to_string(),
699 })?;
700
701 if let Ok((issue, relations)) = client.fetch_single_issue(&issue_id).await {
703 let _ = self.db.upsert_issue(&issue);
704 let _ = self.db.upsert_relations(&issue.id, &relations);
705 }
706
707 Ok(())
708 }
709
710 pub async fn add_comment(
712 &self,
713 issue_id: String,
714 body: String,
715 workspace_id: String,
716 ) -> Result<(), RectilinearError> {
717 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
718 let client =
719 LinearClient::with_http_client(self.client().await.clone(), &api_key);
720 client
721 .add_comment(&issue_id, &body)
722 .await
723 .map_err(|e| RectilinearError::Api {
724 message: e.to_string(),
725 })
726 }
727
728 pub async fn refresh_issue(
731 &self,
732 id_or_identifier: String,
733 workspace_id: String,
734 ) -> Result<Option<RtIssue>, RectilinearError> {
735 let api_key = self.linear_api_key_for_workspace(&workspace_id)?;
736 let client =
737 LinearClient::with_http_client(self.client().await.clone(), &api_key);
738
739 let result = if id_or_identifier.contains('-')
740 && id_or_identifier
741 .chars()
742 .last()
743 .is_some_and(|c| c.is_ascii_digit())
744 {
745 client
746 .fetch_issue_by_identifier(&id_or_identifier)
747 .await
748 .map_err(|e| RectilinearError::Api {
749 message: e.to_string(),
750 })?
751 } else {
752 Some(
753 client
754 .fetch_single_issue(&id_or_identifier)
755 .await
756 .map_err(|e| RectilinearError::Api {
757 message: e.to_string(),
758 })?,
759 )
760 };
761
762 if let Some((issue, relations)) = result {
763 self.db.upsert_issue(&issue)?;
764 self.db.upsert_relations(&issue.id, &relations)?;
765 Ok(Some(RtIssue::from(issue)))
766 } else {
767 Ok(None)
768 }
769 }
770
771 pub async fn embed_issues(
774 &self,
775 team: Option<String>,
776 limit: u32,
777 workspace_id: String,
778 ) -> Result<u64, RectilinearError> {
779 let config = Config::load().unwrap_or_default();
780 let embedder =
781 self.make_embedder(&config)
782 .await?
783 .ok_or_else(|| {
784 RectilinearError::Config {
785 message:
786 "No embedding backend available — set GEMINI_API_KEY or enable local embeddings"
787 .into(),
788 }
789 })?;
790
791 let model_name = embedder.backend_name().to_string();
792 let issues = self
793 .db
794 .get_issues_needing_embedding(team.as_deref(), false, &workspace_id)?;
795
796 let to_process = if limit > 0 {
797 &issues[..std::cmp::min(issues.len(), limit as usize)]
798 } else {
799 &issues
800 };
801 let total = to_process.len() as u64;
802
803 self.set_sync_progress(Some(RtSyncProgress {
804 phase: RtSyncPhase::GeneratingEmbeddings,
805 completed: 0,
806 total: Some(total),
807 }));
808
809 let result: Result<u64, RectilinearError> = async {
810 let mut count = 0u64;
811 for issue in to_process {
812 if let Some(existing_model) = self.db.get_embedding_model(&issue.id)? {
814 if existing_model == model_name {
815 continue;
816 }
817 }
818
819 let chunks = crate::embedding::chunk_text(
820 &issue.title,
821 issue.description.as_deref().unwrap_or(""),
822 512,
823 64,
824 );
825 let embeddings =
826 embedder
827 .embed_batch(&chunks)
828 .await
829 .map_err(|e| RectilinearError::Api {
830 message: e.to_string(),
831 })?;
832
833 let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
834 .into_iter()
835 .zip(embeddings.iter())
836 .enumerate()
837 .map(|(idx, (text, emb))| {
838 (idx, text, crate::embedding::embedding_to_bytes(emb))
839 })
840 .collect();
841
842 self.db
843 .upsert_chunks_with_model(&issue.id, &chunk_data, &model_name)?;
844 count += 1;
845 self.set_sync_progress(Some(RtSyncProgress {
846 phase: RtSyncPhase::GeneratingEmbeddings,
847 completed: count,
848 total: Some(total),
849 }));
850 }
851
852 Ok(count)
853 }
854 .await;
855
856 self.set_sync_progress(None);
857 result
858 }
859}
860
861impl RectilinearEngine {
864 fn set_sync_progress(&self, progress: Option<RtSyncProgress>) {
865 *self.sync_progress.lock().unwrap() = progress;
866 }
867
868 async fn make_embedder(
869 &self,
870 config: &Config,
871 ) -> Result<Option<crate::embedding::Embedder>, RectilinearError> {
872 let key = self
873 .gemini_api_key
874 .as_deref()
875 .or(config.embedding.gemini_api_key.as_deref());
876
877 if let Some(api_key) = key {
878 Ok(Some(
879 crate::embedding::Embedder::new_api_with_http_client(
880 self.client().await.clone(),
881 api_key,
882 )
883 .map_err(|e| RectilinearError::Config {
884 message: e.to_string(),
885 })?,
886 ))
887 } else {
888 #[cfg(feature = "local-embeddings")]
889 {
890 let models_dir = Config::models_dir().map_err(|e| RectilinearError::Config {
891 message: e.to_string(),
892 })?;
893 Ok(Some(
894 crate::embedding::Embedder::new_local(&models_dir).map_err(|e| {
895 RectilinearError::Config {
896 message: e.to_string(),
897 }
898 })?,
899 ))
900 }
901 #[cfg(not(feature = "local-embeddings"))]
902 {
903 Ok(None)
904 }
905 }
906 }
907}