1use std::{path::PathBuf, sync::Arc};
2
3use terraphim_automata::{
4 builder::{Logseq, ThesaurusBuilder},
5 load_thesaurus, AutomataPath,
6};
7use terraphim_persistence::Persistable;
8use terraphim_rolegraph::{RoleGraph, RoleGraphSync};
9use terraphim_types::{
10 Document, IndexedDocument, KnowledgeGraphInputType, RelevanceFunction, RoleName, SearchQuery,
11};
12
13use terraphim_settings::DeviceSettings;
14
15use ahash::AHashMap;
16use async_trait::async_trait;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use thiserror::Error;
21use tokio::sync::Mutex;
22#[cfg(feature = "typescript")]
23use tsify::Tsify;
24
25use crate::llm_router::LlmRouterConfig;
26
27pub mod llm_router;
29
30pub type Result<T> = std::result::Result<T, TerraphimConfigError>;
31
32use opendal::Result as OpendalResult;
33
34type PersistenceResult<T> = std::result::Result<T, terraphim_persistence::Error>;
35
36#[derive(Error, Debug)]
37pub enum TerraphimConfigError {
38 #[error("Unable to load config")]
39 NotFound,
40
41 #[error("At least one role is required")]
42 NoRoles,
43
44 #[error("Profile error")]
45 Profile(String),
46
47 #[error("Persistence error")]
48 Persistence(Box<terraphim_persistence::Error>),
49
50 #[error("Serde JSON error")]
51 Json(#[from] serde_json::Error),
52
53 #[error("Cannot initialize tracing subscriber")]
54 TracingSubscriber(Box<dyn std::error::Error + Send + Sync>),
55
56 #[error("Pipe error")]
57 Pipe(#[from] terraphim_rolegraph::Error),
58
59 #[error("Automata error")]
60 Automata(#[from] terraphim_automata::TerraphimAutomataError),
61
62 #[error("Url error")]
63 Url(#[from] url::ParseError),
64
65 #[error("IO error")]
66 Io(#[from] std::io::Error),
67
68 #[error("Config error")]
69 Config(String),
70}
71
72impl From<terraphim_persistence::Error> for TerraphimConfigError {
73 fn from(error: terraphim_persistence::Error) -> Self {
74 TerraphimConfigError::Persistence(Box::new(error))
75 }
76}
77
78fn expand_path(path: &str) -> PathBuf {
85 let mut result = path.to_string();
86
87 loop {
91 if let Some(start) = result.find("${") {
93 if let Some(colon_pos) = result[start..].find(":-") {
94 let colon_pos = start + colon_pos;
95 let var_name = &result[start + 2..colon_pos];
97 let after_colon = colon_pos + 2;
99 let mut depth = 1;
100 let mut end_pos = after_colon;
101 for (i, c) in result[after_colon..].char_indices() {
102 match c {
103 '{' => depth += 1,
104 '}' => {
105 depth -= 1;
106 if depth == 0 {
107 end_pos = after_colon + i;
108 break;
109 }
110 }
111 _ => {}
112 }
113 }
114 if depth == 0 {
115 let default_value = &result[after_colon..end_pos];
116 let replacement =
117 std::env::var(var_name).unwrap_or_else(|_| default_value.to_string());
118 result = format!(
119 "{}{}{}",
120 &result[..start],
121 replacement,
122 &result[end_pos + 1..]
123 );
124 continue; }
126 }
127 }
128 break;
129 }
130
131 let re_braces = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
133 result = re_braces
134 .replace_all(&result, |caps: ®ex::Captures| {
135 let var_name = &caps[1];
136 if var_name == "HOME" {
137 dirs::home_dir()
138 .map(|p| p.to_string_lossy().to_string())
139 .unwrap_or_else(|| format!("${{{}}}", var_name))
140 } else {
141 std::env::var(var_name).unwrap_or_else(|_| format!("${{{}}}", var_name))
142 }
143 })
144 .to_string();
145
146 let re_dollar = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
148 result = re_dollar
149 .replace_all(&result, |caps: ®ex::Captures| {
150 let var_name = &caps[1];
151 if var_name == "HOME" {
152 dirs::home_dir()
153 .map(|p| p.to_string_lossy().to_string())
154 .unwrap_or_else(|| format!("${}", var_name))
155 } else {
156 std::env::var(var_name).unwrap_or_else(|_| format!("${}", var_name))
157 }
158 })
159 .to_string();
160
161 if result.starts_with('~') {
163 if let Some(home) = dirs::home_dir() {
164 result = result.replacen('~', &home.to_string_lossy(), 1);
165 }
166 }
167
168 PathBuf::from(result)
169}
170
171fn default_context_window() -> Option<u64> {
173 Some(32768)
174}
175
176#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Default)]
181#[cfg_attr(feature = "typescript", derive(Tsify))]
182#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
183pub struct Role {
184 pub shortname: Option<String>,
185 pub name: RoleName,
186 pub relevance_function: RelevanceFunction,
188 pub terraphim_it: bool,
189 pub theme: String,
190 pub kg: Option<KnowledgeGraph>,
191 pub haystacks: Vec<Haystack>,
192 #[serde(default)]
194 pub llm_enabled: bool,
195 #[serde(default)]
197 pub llm_api_key: Option<String>,
198 #[serde(default)]
200 pub llm_model: Option<String>,
201 #[serde(default)]
203 pub llm_auto_summarize: bool,
204 #[serde(default)]
206 pub llm_chat_enabled: bool,
207 #[serde(default)]
209 pub llm_chat_system_prompt: Option<String>,
210 #[serde(default)]
212 pub llm_chat_model: Option<String>,
213 #[serde(default = "default_context_window")]
215 pub llm_context_window: Option<u64>,
216 #[serde(flatten)]
217 #[schemars(skip)]
218 #[cfg_attr(feature = "typescript", tsify(type = "Record<string, unknown>"))]
219 pub extra: AHashMap<String, Value>,
220 #[serde(default)]
222 pub llm_router_enabled: bool,
223 #[serde(default)]
225 pub llm_router_config: Option<LlmRouterConfig>,
226}
227
228impl Role {
229 pub fn new(name: impl Into<RoleName>) -> Self {
231 Self {
232 shortname: None,
233 name: name.into(),
234 relevance_function: RelevanceFunction::TitleScorer,
235 terraphim_it: false,
236 theme: "default".to_string(),
237 kg: None,
238 haystacks: vec![],
239 llm_enabled: false,
240 llm_api_key: None,
241 llm_model: None,
242 llm_auto_summarize: false,
243 llm_chat_enabled: false,
244 llm_chat_system_prompt: None,
245 llm_chat_model: None,
246 llm_context_window: default_context_window(),
247 extra: AHashMap::new(),
248 llm_router_enabled: false,
249 llm_router_config: None,
250 }
251 }
252
253 pub fn has_llm_config(&self) -> bool {
255 self.llm_enabled && self.llm_api_key.is_some() && self.llm_model.is_some()
256 }
257
258 pub fn get_llm_model(&self) -> Option<&str> {
260 self.llm_model.as_deref()
261 }
262}
263
264use anyhow::Context;
265#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, JsonSchema)]
270#[cfg_attr(feature = "typescript", derive(Tsify))]
271#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
272pub enum ServiceType {
273 Ripgrep,
275 Atomic,
277 QueryRs,
279 ClickUp,
281 Mcp,
283 Perplexity,
285 GrepApp,
287 AiAssistant,
289 Quickwit,
291}
292
293#[derive(Debug, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
298#[cfg_attr(feature = "typescript", derive(Tsify))]
299#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
300pub struct Haystack {
301 pub location: String,
303 pub service: ServiceType,
305 #[serde(default)]
309 pub read_only: bool,
310 #[serde(default)]
314 pub fetch_content: bool,
315 #[serde(default)]
318 pub atomic_server_secret: Option<String>,
319 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
323 pub extra_parameters: std::collections::HashMap<String, String>,
324}
325
326impl Serialize for Haystack {
327 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
328 where
329 S: serde::Serializer,
330 {
331 use serde::ser::SerializeStruct;
332
333 let mut field_count = 3; let include_atomic_secret =
338 self.service == ServiceType::Atomic && self.atomic_server_secret.is_some();
339 if include_atomic_secret {
340 field_count += 1;
341 }
342
343 if !self.extra_parameters.is_empty() {
345 field_count += 1;
346 }
347
348 let mut state = serializer.serialize_struct("Haystack", field_count)?;
349 state.serialize_field("location", &self.location)?;
350 state.serialize_field("service", &self.service)?;
351 state.serialize_field("read_only", &self.read_only)?;
352
353 if include_atomic_secret {
355 state.serialize_field("atomic_server_secret", &self.atomic_server_secret)?;
356 }
357
358 if !self.extra_parameters.is_empty() {
360 state.serialize_field("extra_parameters", &self.extra_parameters)?;
361 }
362
363 state.end()
364 }
365}
366
367impl Haystack {
368 pub fn new(location: String, service: ServiceType, read_only: bool) -> Self {
370 Self {
371 location,
372 service,
373 read_only,
374 fetch_content: false,
375 atomic_server_secret: None,
376 extra_parameters: std::collections::HashMap::new(),
377 }
378 }
379
380 pub fn with_atomic_secret(mut self, secret: Option<String>) -> Self {
382 if self.service == ServiceType::Atomic {
384 self.atomic_server_secret = secret;
385 }
386 self
387 }
388
389 pub fn with_extra_parameters(
391 mut self,
392 params: std::collections::HashMap<String, String>,
393 ) -> Self {
394 self.extra_parameters = params;
395 self
396 }
397
398 pub fn with_extra_parameter(mut self, key: String, value: String) -> Self {
400 self.extra_parameters.insert(key, value);
401 self
402 }
403
404 pub fn get_extra_parameters(&self) -> &std::collections::HashMap<String, String> {
406 &self.extra_parameters
407 }
408}
409
410#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
413#[cfg_attr(feature = "typescript", derive(Tsify))]
414#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
415pub struct KnowledgeGraph {
416 #[schemars(with = "Option<String>")]
418 pub automata_path: Option<AutomataPath>,
419 pub knowledge_graph_local: Option<KnowledgeGraphLocal>,
421 pub public: bool,
422 pub publish: bool,
423}
424impl KnowledgeGraph {
426 pub fn is_set(&self) -> bool {
427 self.automata_path.is_some() || self.knowledge_graph_local.is_some()
428 }
429}
430
431#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
432#[cfg_attr(feature = "typescript", derive(Tsify))]
433#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
434pub struct KnowledgeGraphLocal {
435 pub input_type: KnowledgeGraphInputType,
436 pub path: PathBuf,
437}
438#[derive(Debug)]
457pub struct ConfigBuilder {
458 config: Config,
459 device_settings: DeviceSettings,
460 #[allow(dead_code)]
461 settings_path: PathBuf,
462}
463
464impl ConfigBuilder {
465 pub fn new() -> Self {
467 Self {
468 config: Config::empty(),
469 device_settings: DeviceSettings::new(),
470 settings_path: PathBuf::new(),
471 }
472 }
473 pub fn new_with_id(id: ConfigId) -> Self {
474 let device_settings = match id {
475 ConfigId::Embedded => DeviceSettings::default_embedded(),
476 _ => DeviceSettings::new(),
477 };
478
479 Self {
480 config: Config {
481 id,
482 ..Config::empty()
483 },
484 device_settings,
485 settings_path: PathBuf::new(),
486 }
487 }
488 pub fn build_default_embedded(mut self) -> Self {
489 self.config.id = ConfigId::Embedded;
490
491 let mut default_role = Role::new("Default");
493 default_role.shortname = Some("Default".to_string());
494 default_role.theme = "spacelab".to_string();
495 default_role.haystacks = vec![Haystack {
496 location: "docs/src".to_string(),
497 service: ServiceType::Ripgrep,
498 read_only: true,
499 fetch_content: false,
500 atomic_server_secret: None,
501 extra_parameters: std::collections::HashMap::new(),
502 }];
503
504 self = self.add_role("Default", default_role);
505
506 let mut terraphim_role = Role::new("Terraphim Engineer");
508 terraphim_role.shortname = Some("TerraEng".to_string());
509 terraphim_role.relevance_function = RelevanceFunction::TerraphimGraph;
510 terraphim_role.terraphim_it = true;
511 terraphim_role.theme = "lumen".to_string();
512 terraphim_role.kg = Some(KnowledgeGraph {
513 automata_path: None,
514 knowledge_graph_local: Some(KnowledgeGraphLocal {
515 input_type: KnowledgeGraphInputType::Markdown,
516 path: PathBuf::from("docs/src/kg"),
517 }),
518 public: true,
519 publish: true,
520 });
521 terraphim_role.haystacks = vec![Haystack {
522 location: "docs/src".to_string(),
523 service: ServiceType::Ripgrep,
524 read_only: true,
525 fetch_content: false,
526 atomic_server_secret: None,
527 extra_parameters: std::collections::HashMap::new(),
528 }];
529
530 self = self.add_role("Terraphim Engineer", terraphim_role);
531
532 let mut rust_engineer_role = Role::new("Rust Engineer");
534 rust_engineer_role.shortname = Some("rust-engineer".to_string());
535 rust_engineer_role.theme = "cosmo".to_string();
536 rust_engineer_role.haystacks = vec![Haystack {
537 location: "https://query.rs".to_string(),
538 service: ServiceType::QueryRs,
539 read_only: true,
540 fetch_content: false,
541 atomic_server_secret: None,
542 extra_parameters: std::collections::HashMap::new(),
543 }];
544
545 self = self.add_role("Rust Engineer", rust_engineer_role);
546
547 self.config.default_role = RoleName::new("Terraphim Engineer");
549 self.config.selected_role = RoleName::new("Terraphim Engineer");
550 self
551 }
552
553 pub fn get_default_data_path(&self) -> PathBuf {
554 expand_path(&self.device_settings.default_data_path)
555 }
556 pub fn build_default_server(mut self) -> Self {
557 self.config.id = ConfigId::Server;
558 let cwd = std::env::current_dir()
560 .context("Failed to get current directory")
561 .unwrap();
562 log::info!("Current working directory: {}", cwd.display());
563 let system_operator_haystack = if cwd.ends_with("terraphim_server") {
564 cwd.join("fixtures/haystack/")
565 } else {
566 cwd.join("terraphim_server/fixtures/haystack/")
567 };
568
569 log::debug!("system_operator_haystack: {:?}", system_operator_haystack);
570 let automata_test_path = if cwd.ends_with("terraphim_server") {
571 cwd.join("fixtures/term_to_id.json")
572 } else {
573 cwd.join("terraphim_server/fixtures/term_to_id.json")
574 };
575 log::debug!("Test automata_test_path {:?}", automata_test_path);
576 let automata_remote = AutomataPath::from_remote(
577 "https://staging-storage.terraphim.io/thesaurus_Default.json",
578 )
579 .unwrap();
580 log::info!("Automata remote URL: {automata_remote}");
581 self.global_shortcut("Ctrl+X")
582 .add_role("Default", {
583 let mut default_role = Role::new("Default");
584 default_role.shortname = Some("Default".to_string());
585 default_role.theme = "spacelab".to_string();
586 default_role.haystacks = vec![Haystack {
587 location: system_operator_haystack.to_string_lossy().to_string(),
588 service: ServiceType::Ripgrep,
589 read_only: false,
590 fetch_content: false,
591 atomic_server_secret: None,
592 extra_parameters: std::collections::HashMap::new(),
593 }];
594 default_role
595 })
596 .add_role("Engineer", {
597 let mut engineer_role = Role::new("Engineer");
598 engineer_role.shortname = Some("Engineer".into());
599 engineer_role.relevance_function = RelevanceFunction::TerraphimGraph;
600 engineer_role.terraphim_it = true;
601 engineer_role.theme = "lumen".to_string();
602 engineer_role.kg = Some(KnowledgeGraph {
603 automata_path: Some(automata_remote.clone()),
604 knowledge_graph_local: Some(KnowledgeGraphLocal {
605 input_type: KnowledgeGraphInputType::Markdown,
606 path: system_operator_haystack.clone(),
607 }),
608 public: true,
609 publish: true,
610 });
611 engineer_role.haystacks = vec![Haystack {
612 location: system_operator_haystack.to_string_lossy().to_string(),
613 service: ServiceType::Ripgrep,
614 read_only: false,
615 fetch_content: false,
616 atomic_server_secret: None,
617 extra_parameters: std::collections::HashMap::new(),
618 }];
619 engineer_role
620 })
621 .add_role("System Operator", {
622 let mut system_operator_role = Role::new("System Operator");
623 system_operator_role.shortname = Some("operator".to_string());
624 system_operator_role.relevance_function = RelevanceFunction::TerraphimGraph;
625 system_operator_role.terraphim_it = true;
626 system_operator_role.theme = "superhero".to_string();
627 system_operator_role.kg = Some(KnowledgeGraph {
628 automata_path: Some(automata_remote.clone()),
629 knowledge_graph_local: Some(KnowledgeGraphLocal {
630 input_type: KnowledgeGraphInputType::Markdown,
631 path: system_operator_haystack.clone(),
632 }),
633 public: true,
634 publish: true,
635 });
636 system_operator_role.haystacks = vec![Haystack {
637 location: system_operator_haystack.to_string_lossy().to_string(),
638 service: ServiceType::Ripgrep,
639 read_only: false,
640 fetch_content: false,
641 atomic_server_secret: None,
642 extra_parameters: std::collections::HashMap::new(),
643 }];
644 system_operator_role
645 })
646 .default_role("Default")
647 .unwrap()
648 }
649
650 pub fn build_default_desktop(mut self) -> Self {
651 let default_data_path = self.get_default_data_path();
652 log::info!("Documents path: {:?}", default_data_path);
654 self.config.id = ConfigId::Desktop;
655 self.global_shortcut("Ctrl+X")
656 .add_role("Default", {
657 let mut default_role = Role::new("Default");
658 default_role.shortname = Some("Default".to_string());
659 default_role.theme = "spacelab".to_string();
660 default_role.haystacks = vec![Haystack {
661 location: default_data_path.to_string_lossy().to_string(),
662 service: ServiceType::Ripgrep,
663 read_only: false,
664 fetch_content: false,
665 atomic_server_secret: None,
666 extra_parameters: std::collections::HashMap::new(),
667 }];
668 default_role
669 })
670 .add_role("Terraphim Engineer", {
671 let mut terraphim_engineer_role = Role::new("Terraphim Engineer");
672 terraphim_engineer_role.shortname = Some("TerraEng".to_string());
673 terraphim_engineer_role.relevance_function = RelevanceFunction::TerraphimGraph;
674 terraphim_engineer_role.terraphim_it = true;
675 terraphim_engineer_role.theme = "lumen".to_string();
676 terraphim_engineer_role.kg = Some(KnowledgeGraph {
677 automata_path: None, knowledge_graph_local: Some(KnowledgeGraphLocal {
679 input_type: KnowledgeGraphInputType::Markdown,
680 path: default_data_path.join("kg"),
681 }),
682 public: true,
683 publish: true,
684 });
685 terraphim_engineer_role.haystacks = vec![Haystack {
686 location: default_data_path.to_string_lossy().to_string(),
687 service: ServiceType::Ripgrep,
688 read_only: false,
689 fetch_content: false,
690 atomic_server_secret: None,
691 extra_parameters: std::collections::HashMap::new(),
692 }];
693 terraphim_engineer_role
694 })
695 .add_role("Rust Engineer", {
696 let mut rust_engineer_role = Role::new("Rust Engineer");
697 rust_engineer_role.shortname = Some("rust-engineer".to_string());
698 rust_engineer_role.theme = "cosmo".to_string();
699 rust_engineer_role.haystacks = vec![Haystack {
700 location: "https://query.rs".to_string(),
701 service: ServiceType::QueryRs,
702 read_only: true,
703 fetch_content: false,
704 atomic_server_secret: None,
705 extra_parameters: std::collections::HashMap::new(),
706 }];
707 rust_engineer_role
708 })
709 .default_role("Terraphim Engineer")
710 .unwrap()
711 }
712
713 pub fn from_config(
718 config: Config,
719 device_settings: DeviceSettings,
720 settings_path: PathBuf,
721 ) -> Self {
722 Self {
723 config,
724 device_settings,
725 settings_path,
726 }
727 }
728
729 pub fn global_shortcut(mut self, global_shortcut: &str) -> Self {
731 self.config.global_shortcut = global_shortcut.to_string();
732 self
733 }
734
735 pub fn add_role(mut self, role_name: &str, role: Role) -> Self {
737 let role_name = RoleName::new(role_name);
738 if self.config.roles.is_empty() {
740 self.config.default_role = role_name.clone();
741 }
742 self.config.roles.insert(role_name, role);
743
744 self
745 }
746
747 pub fn default_role(mut self, default_role: &str) -> Result<Self> {
749 let default_role = RoleName::new(default_role);
750 if !self.config.roles.contains_key(&default_role) {
752 return Err(TerraphimConfigError::Profile(format!(
753 "Role `{}` does not exist",
754 default_role
755 )));
756 }
757
758 self.config.default_role = default_role;
759 Ok(self)
760 }
761
762 pub fn build(self) -> Result<Config> {
764 Ok(self.config)
770 }
771}
772
773impl Default for ConfigBuilder {
774 fn default() -> Self {
775 Self::new()
776 }
777}
778
779#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
780#[cfg_attr(feature = "typescript", derive(Tsify))]
781#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
782pub enum ConfigId {
783 Server,
784 Desktop,
785 Embedded,
786}
787
788#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
792#[cfg_attr(feature = "typescript", derive(Tsify))]
793#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
794pub struct Config {
795 pub id: ConfigId,
797 pub global_shortcut: String,
799 #[schemars(skip)]
801 pub roles: AHashMap<RoleName, Role>,
802 pub default_role: RoleName,
804 pub selected_role: RoleName,
805}
806
807impl Config {
808 fn empty() -> Self {
809 Self {
810 id: ConfigId::Server, global_shortcut: "Ctrl+X".to_string(),
812 roles: AHashMap::new(),
813 default_role: RoleName::new("Default"),
814 selected_role: RoleName::new("Default"),
815 }
816 }
817}
818
819impl Default for Config {
820 fn default() -> Self {
821 Self::empty()
822 }
823}
824
825#[async_trait]
826impl Persistable for Config {
827 fn new(_key: String) -> Self {
828 Config::empty()
830 }
831
832 async fn save_to_one(&self, profile_name: &str) -> PersistenceResult<()> {
834 self.save_to_profile(profile_name).await?;
835 Ok(())
836 }
837
838 async fn save(&self) -> PersistenceResult<()> {
840 let _op = &self.load_config().await?.1;
841 let _ = self.save_to_all().await?;
842 Ok(())
843 }
844
845 async fn load(&mut self) -> PersistenceResult<Self> {
847 let op = &self.load_config().await?.1;
848 let key = self.get_key();
849 let obj = self.load_from_operator(&key, op).await?;
850 Ok(obj)
851 }
852
853 fn get_key(&self) -> String {
855 match self.id {
856 ConfigId::Server => "server",
857 ConfigId::Desktop => "desktop",
858 ConfigId::Embedded => "embedded",
859 }
860 .to_string()
861 + "_config.json"
862 }
863}
864
865#[derive(Debug, Clone)]
870pub struct ConfigState {
871 pub config: Arc<Mutex<Config>>,
873 pub roles: AHashMap<RoleName, RoleGraphSync>,
875}
876
877impl ConfigState {
878 pub async fn new(config: &mut Config) -> Result<Self> {
883 let mut roles = AHashMap::new();
884 for (name, role) in &config.roles {
885 let role_name = name.clone();
886 log::info!("Creating role {}", role_name);
887 if role.relevance_function == RelevanceFunction::TerraphimGraph {
888 if let Some(kg) = &role.kg {
889 if let Some(automata_path) = &kg.automata_path {
890 log::info!(
891 "Role {} is configured correctly with automata_path",
892 role_name
893 );
894 log::info!("Loading Role `{}` - URL: {:?}", role_name, automata_path);
895
896 match load_thesaurus(automata_path).await {
898 Ok(thesaurus) => {
899 log::info!("Successfully loaded thesaurus from automata path");
900 let rolegraph =
901 RoleGraph::new(role_name.clone(), thesaurus).await?;
902 roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph));
903 }
904 Err(e) => {
905 log::warn!("Failed to load thesaurus from automata path: {:?}", e);
906 }
907 }
908 } else if let Some(kg_local) = &kg.knowledge_graph_local {
909 log::info!(
911 "Role {} has no automata_path, building thesaurus from local KG files at {:?}",
912 role_name,
913 kg_local.path
914 );
915 let logseq_builder = Logseq::default();
916 match logseq_builder
917 .build(role_name.as_lowercase().to_string(), kg_local.path.clone())
918 .await
919 {
920 Ok(thesaurus) => {
921 log::info!(
922 "Successfully built thesaurus from local KG for role {}",
923 role_name
924 );
925 let rolegraph =
926 RoleGraph::new(role_name.clone(), thesaurus).await?;
927 roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph));
928 }
929 Err(e) => {
930 log::error!(
931 "Failed to build thesaurus from local KG for role {}: {:?}",
932 role_name,
933 e
934 );
935 }
936 }
937 } else {
938 log::warn!("Role {} is configured for TerraphimGraph but has neither automata_path nor knowledge_graph_local defined.", role_name);
939 }
940 }
941 }
942 }
943
944 Ok(ConfigState {
945 config: Arc::new(Mutex::new(config.clone())),
946 roles,
947 })
948 }
949
950 pub async fn get_default_role(&self) -> RoleName {
952 let config = self.config.lock().await;
953 config.default_role.clone()
954 }
955
956 pub async fn get_selected_role(&self) -> RoleName {
957 let config = self.config.lock().await;
958 config.selected_role.clone()
959 }
960
961 pub async fn get_role(&self, role: &RoleName) -> Option<Role> {
963 let config = self.config.lock().await;
964 config.roles.get(role).cloned()
965 }
966
967 pub async fn add_to_roles(&mut self, document: &Document) -> OpendalResult<()> {
969 let id = document.id.clone();
970
971 for rolegraph_state in self.roles.values() {
972 let mut rolegraph = rolegraph_state.lock().await;
973 rolegraph.insert_document(&id, document.clone());
974 }
975 Ok(())
976 }
977
978 pub async fn search_indexed_documents(
981 &self,
982 search_query: &SearchQuery,
983 role: &Role,
984 ) -> Vec<IndexedDocument> {
985 log::debug!("search_documents: {:?}", search_query);
986
987 log::debug!("Role for search_documents: {:#?}", role);
988
989 let role_name = &role.name;
990 log::debug!("Role name for searching {role_name}");
991 log::debug!("All roles defined {:?}", self.roles.clone().into_keys());
992 let role = match self.roles.get(role_name) {
994 Some(role) => role.lock().await,
995 None => {
996 log::error!(
998 "Role `{}` does not exist or RoleGraph isn't populated",
999 role_name
1000 );
1001 return Vec::new();
1002 }
1003 };
1004 let documents = if search_query.is_multi_term_query() {
1005 let all_terms: Vec<&str> = search_query
1007 .get_all_terms()
1008 .iter()
1009 .map(|t| t.as_str())
1010 .collect();
1011 let operator = search_query.get_operator();
1012
1013 log::debug!(
1014 "Performing multi-term search with {} terms using {:?} operator",
1015 all_terms.len(),
1016 operator
1017 );
1018
1019 role.query_graph_with_operators(
1020 &all_terms,
1021 &operator,
1022 search_query.skip,
1023 search_query.limit,
1024 )
1025 .unwrap_or_else(|e| {
1026 log::error!(
1027 "Error while searching graph with operators for documents: {:?}",
1028 e
1029 );
1030 vec![]
1031 })
1032 } else {
1033 role.query_graph(
1035 search_query.search_term.as_str(),
1036 search_query.skip,
1037 search_query.limit,
1038 )
1039 .unwrap_or_else(|e| {
1040 log::error!("Error while searching graph for documents: {:?}", e);
1041 vec![]
1042 })
1043 };
1044
1045 documents.into_iter().map(|(_id, doc)| doc).collect()
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use std::io::Write;
1053 use tempfile::tempfile;
1054 use tokio::test;
1055
1056 #[test]
1057 async fn test_write_config_to_json() {
1058 let config = Config::empty();
1059 let json_str = serde_json::to_string_pretty(&config).unwrap();
1060
1061 let mut tempfile = tempfile().unwrap();
1062 tempfile.write_all(json_str.as_bytes()).unwrap();
1063 }
1064
1065 #[test]
1066 async fn test_get_key() {
1067 let config = Config::empty();
1068 serde_json::to_string_pretty(&config).unwrap();
1069 assert!(config.get_key().ends_with(".json"));
1070 }
1071
1072 #[tokio::test]
1073 async fn test_save_all() {
1074 terraphim_persistence::DeviceStorage::init_memory_only()
1076 .await
1077 .unwrap();
1078 let config = Config::empty();
1079 config.save().await.unwrap();
1080 }
1081
1082 #[tokio::test]
1083 async fn test_save_one_s3() {
1084 terraphim_persistence::DeviceStorage::init_memory_only()
1086 .await
1087 .unwrap();
1088 let config = Config::empty();
1089 println!("{:#?}", config);
1090 match config.save_to_one("s3").await {
1091 Ok(_) => println!("Successfully saved to s3 (env provides s3 profile)"),
1092 Err(e) => {
1093 println!(
1094 "Expected error saving to s3 in test environment without s3 profile: {:?}",
1095 e
1096 );
1097 }
1099 }
1100 }
1101
1102 #[tokio::test]
1103 async fn load_s3() {
1104 let mut config = Config::empty();
1105 match config.load().await {
1106 Ok(loaded_config) => {
1107 println!("Successfully loaded config: {:#?}", loaded_config);
1108 }
1109 Err(e) => {
1110 println!(
1111 "Expected error loading config (no S3 data in test environment): {:?}",
1112 e
1113 );
1114 }
1116 }
1117 }
1118
1119 #[tokio::test]
1120 async fn test_save_one_memory() {
1121 terraphim_persistence::DeviceStorage::init_memory_only()
1123 .await
1124 .unwrap();
1125 let config = Config::empty();
1126 config.save_to_one("memory").await.unwrap();
1127 }
1128
1129 #[test]
1130 async fn test_write_config_to_toml() {
1131 let config = Config::empty();
1132 let toml = toml::to_string_pretty(&config).unwrap();
1133 toml::from_str::<Config>(&toml).unwrap();
1135 }
1136
1137 #[tokio::test]
1138 async fn test_config_builder() {
1139 let automata_remote = AutomataPath::from_remote(
1140 "https://staging-storage.terraphim.io/thesaurus_Default.json",
1141 )
1142 .unwrap();
1143 let config = ConfigBuilder::new()
1144 .global_shortcut("Ctrl+X")
1145 .add_role("Default", {
1146 let mut default_role = Role::new("Default");
1147 default_role.shortname = Some("Default".to_string());
1148 default_role.theme = "spacelab".to_string();
1149 default_role.haystacks = vec![Haystack {
1150 location: "localsearch".to_string(),
1151 service: ServiceType::Ripgrep,
1152 read_only: false,
1153 fetch_content: false,
1154 atomic_server_secret: None,
1155 extra_parameters: std::collections::HashMap::new(),
1156 }];
1157 default_role
1158 })
1159 .add_role("Engineer", {
1160 let mut engineer_role = Role::new("Engineer");
1161 engineer_role.shortname = Some("Engineer".to_string());
1162 engineer_role.theme = "lumen".to_string();
1163 engineer_role.haystacks = vec![Haystack {
1164 location: "localsearch".to_string(),
1165 service: ServiceType::Ripgrep,
1166 read_only: false,
1167 fetch_content: false,
1168 atomic_server_secret: None,
1169 extra_parameters: std::collections::HashMap::new(),
1170 }];
1171 engineer_role
1172 })
1173 .add_role("System Operator", {
1174 let mut system_operator_role = Role::new("System Operator");
1175 system_operator_role.shortname = Some("operator".to_string());
1176 system_operator_role.relevance_function = RelevanceFunction::TerraphimGraph;
1177 system_operator_role.terraphim_it = true;
1178 system_operator_role.theme = "superhero".to_string();
1179 system_operator_role.kg = Some(KnowledgeGraph {
1180 automata_path: Some(automata_remote.clone()),
1181 knowledge_graph_local: Some(KnowledgeGraphLocal {
1182 input_type: KnowledgeGraphInputType::Markdown,
1183 path: PathBuf::from("~/pkm"),
1184 }),
1185 public: true,
1186 publish: true,
1187 });
1188 system_operator_role.haystacks = vec![Haystack {
1189 location: "/tmp/system_operator/pages/".to_string(),
1190 service: ServiceType::Ripgrep,
1191 read_only: false,
1192 fetch_content: false,
1193 atomic_server_secret: None,
1194 extra_parameters: std::collections::HashMap::new(),
1195 }];
1196 system_operator_role
1197 })
1198 .default_role("Default")
1199 .unwrap()
1200 .build()
1201 .unwrap();
1202
1203 assert_eq!(config.roles.len(), 3);
1204 assert_eq!(config.default_role, RoleName::new("Default"));
1205 }
1206
1207 #[test]
1208 async fn test_update_global_shortcut() {
1209 let config = ConfigBuilder::new()
1210 .add_role("dummy", dummy_role())
1211 .build()
1212 .unwrap();
1213 assert_eq!(config.global_shortcut, "Ctrl+X");
1214 let device_settings = DeviceSettings::new();
1215 let settings_path = PathBuf::from(".");
1216 let new_config = ConfigBuilder::from_config(config, device_settings, settings_path)
1217 .global_shortcut("Ctrl+/")
1218 .build()
1219 .unwrap();
1220
1221 assert_eq!(new_config.global_shortcut, "Ctrl+/");
1222 }
1223
1224 fn dummy_role() -> Role {
1225 let mut role = Role::new("Father");
1226 role.shortname = Some("father".into());
1227 role.theme = "lumen".to_string();
1228 role.kg = Some(KnowledgeGraph {
1229 automata_path: Some(AutomataPath::local_example()),
1230 knowledge_graph_local: None,
1231 public: true,
1232 publish: true,
1233 });
1234 role.haystacks = vec![Haystack {
1235 location: "localsearch".to_string(),
1236 service: ServiceType::Ripgrep,
1237 read_only: false,
1238 fetch_content: false,
1239 atomic_server_secret: None,
1240 extra_parameters: std::collections::HashMap::new(),
1241 }];
1242 role
1243 }
1244
1245 #[test]
1246 async fn test_add_role() {
1247 let config = ConfigBuilder::new()
1249 .add_role("Father", dummy_role())
1250 .build()
1251 .unwrap();
1252
1253 assert!(config.roles.contains_key(&RoleName::new("Father")));
1254 assert_eq!(config.roles.len(), 1);
1255 assert_eq!(&config.default_role, &RoleName::new("Father"));
1256 assert_eq!(config.roles[&RoleName::new("Father")], dummy_role());
1257 }
1258
1259 #[tokio::test]
1261 async fn test_config_with_id_desktop() {
1262 let config = match ConfigBuilder::new_with_id(ConfigId::Desktop).build() {
1263 Ok(mut config) => match config.load().await {
1264 Ok(config) => config,
1265 Err(e) => {
1266 log::info!("Failed to load config: {:?}", e);
1267
1268 ConfigBuilder::new()
1269 .build_default_desktop()
1270 .build()
1271 .unwrap()
1272 }
1273 },
1274 Err(e) => panic!("Failed to build config: {:?}", e),
1275 };
1276 assert_eq!(config.id, ConfigId::Desktop);
1277 }
1278 #[tokio::test]
1280 async fn test_config_with_id_server() {
1281 let config = match ConfigBuilder::new_with_id(ConfigId::Server).build() {
1282 Ok(mut local_config) => match local_config.load().await {
1283 Ok(config) => config,
1284 Err(e) => {
1285 log::info!("Failed to load config: {:?}", e);
1286
1287 ConfigBuilder::new().build_default_server().build().unwrap()
1288 }
1289 },
1290 Err(e) => panic!("Failed to build config: {:?}", e),
1291 };
1292 assert_eq!(config.id, ConfigId::Server);
1293 }
1294
1295 #[tokio::test]
1296 async fn test_config_with_id_embedded() {
1297 let config = match ConfigBuilder::new_with_id(ConfigId::Embedded).build() {
1298 Ok(mut config) => match config.load().await {
1299 Ok(config) => config,
1300 Err(e) => {
1301 log::info!("Failed to load config: {:?}", e);
1302
1303 ConfigBuilder::new()
1304 .build_default_embedded()
1305 .build()
1306 .unwrap()
1307 }
1308 },
1309 Err(e) => panic!("Failed to build config: {:?}", e),
1310 };
1311 assert_eq!(config.id, ConfigId::Embedded);
1312 }
1313
1314 #[tokio::test]
1315 #[ignore]
1316 async fn test_at_least_one_role() {
1317 let config = ConfigBuilder::new().build();
1318 assert!(config.is_err());
1319 assert!(matches!(config.unwrap_err(), TerraphimConfigError::NoRoles));
1320 }
1321
1322 #[tokio::test]
1323 async fn test_json_serialization() {
1324 let config = Config::default();
1325 let json = serde_json::to_string_pretty(&config).unwrap();
1326 log::debug!("Config: {:#?}", config);
1327 assert!(!json.is_empty());
1328 }
1329
1330 #[tokio::test]
1331 async fn test_toml_serialization() {
1332 let config = Config::default();
1333 let toml = toml::to_string_pretty(&config).unwrap();
1334 log::debug!("Config: {:#?}", config);
1335 assert!(!toml.is_empty());
1336 }
1337
1338 #[tokio::test]
1339 async fn test_expand_path_home() {
1340 let home = dirs::home_dir().expect("HOME should be set");
1341 let home_str = home.to_string_lossy();
1342
1343 let result = expand_path("${HOME}/.terraphim");
1345 assert_eq!(result, home.join(".terraphim"));
1346
1347 let result = expand_path("$HOME/.terraphim");
1349 assert_eq!(result, home.join(".terraphim"));
1350
1351 let result = expand_path("~/.terraphim");
1353 assert_eq!(result, home.join(".terraphim"));
1354
1355 let result = expand_path("${TERRAPHIM_DATA_PATH:-${HOME}/.terraphim}");
1357 assert_eq!(result, home.join(".terraphim"));
1358
1359 std::env::set_var("TERRAPHIM_TEST_PATH", "/custom/path");
1361 let result = expand_path("${TERRAPHIM_TEST_PATH:-${HOME}/.default}");
1362 assert_eq!(result, PathBuf::from("/custom/path"));
1363 std::env::remove_var("TERRAPHIM_TEST_PATH");
1364
1365 println!("expand_path tests passed!");
1366 println!("HOME = {}", home_str);
1367 println!(
1368 "${{HOME}}/.terraphim -> {:?}",
1369 expand_path("${HOME}/.terraphim")
1370 );
1371 }
1372}