terraphim_config/
lib.rs

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
27// LLM Router configuration
28pub 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
78/// Expand shell-like variables in a path string.
79///
80/// Supports:
81/// - `${HOME}` or `$HOME` -> user's home directory
82/// - `${TERRAPHIM_DATA_PATH:-default}` -> environment variable with default value
83/// - `~` at the start -> user's home directory
84fn expand_path(path: &str) -> PathBuf {
85    let mut result = path.to_string();
86
87    // Handle ${VAR:-default} syntax (environment variable with default)
88    // This regex handles nested ${...} in the default value by using a greedy match
89    // that captures everything until the last }
90    loop {
91        // Find ${VAR:-...} pattern manually to handle nested braces
92        if let Some(start) = result.find("${") {
93            if let Some(colon_pos) = result[start..].find(":-") {
94                let colon_pos = start + colon_pos;
95                // Find the variable name
96                let var_name = &result[start + 2..colon_pos];
97                // Find the matching closing brace by counting braces
98                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; // Process again in case there are more patterns
125                }
126            }
127        }
128        break;
129    }
130
131    // Handle ${VAR} syntax
132    let re_braces = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
133    result = re_braces
134        .replace_all(&result, |caps: &regex::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    // Handle $VAR syntax (without braces)
147    let re_dollar = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
148    result = re_dollar
149        .replace_all(&result, |caps: &regex::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    // Handle ~ at the beginning of the path
162    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
171/// Default context window size for LLM requests
172fn default_context_window() -> Option<u64> {
173    Some(32768)
174}
175
176/// A role is a collection of settings for a specific user
177///
178/// It contains a user's knowledge graph, a list of haystacks, as
179/// well as preferences for the relevance function and theme
180#[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    /// The relevance function used to rank search results
187    pub relevance_function: RelevanceFunction,
188    pub terraphim_it: bool,
189    pub theme: String,
190    pub kg: Option<KnowledgeGraph>,
191    pub haystacks: Vec<Haystack>,
192    /// Enable AI-powered article summaries using LLM providers
193    #[serde(default)]
194    pub llm_enabled: bool,
195    /// API key for LLM service
196    #[serde(default)]
197    pub llm_api_key: Option<String>,
198    /// Model to use for generating summaries (e.g., "openai/gpt-3.5-turbo", "gemma3:270m")
199    #[serde(default)]
200    pub llm_model: Option<String>,
201    /// Automatically summarize search results using LLM
202    #[serde(default)]
203    pub llm_auto_summarize: bool,
204    /// Enable Chat interface backed by LLM
205    #[serde(default)]
206    pub llm_chat_enabled: bool,
207    /// Optional system prompt to use for chat conversations
208    #[serde(default)]
209    pub llm_chat_system_prompt: Option<String>,
210    /// Optional chat model override (falls back to llm_model)
211    #[serde(default)]
212    pub llm_chat_model: Option<String>,
213    /// Maximum tokens for LLM context window (default: 32768)
214    #[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    /// Enable intelligent LLM routing with 6-phase architecture
221    #[serde(default)]
222    pub llm_router_enabled: bool,
223    /// Configuration for intelligent routing behavior
224    #[serde(default)]
225    pub llm_router_config: Option<LlmRouterConfig>,
226}
227
228impl Role {
229    /// Create a new Role with default values for all fields
230    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    /// Check if LLM is properly configured for this role
254    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    /// Get the LLM model name, providing a sensible default
259    pub fn get_llm_model(&self) -> Option<&str> {
260        self.llm_model.as_deref()
261    }
262}
263
264use anyhow::Context;
265/// The service used for indexing documents
266///
267/// Each service assumes documents to be stored in a specific format
268/// and uses a specific indexing algorithm
269#[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    /// Use ripgrep as the indexing service
274    Ripgrep,
275    /// Use an Atomic Server as the indexing service
276    Atomic,
277    /// Use query.rs as the indexing service
278    QueryRs,
279    /// Use ClickUp API as the indexing service
280    ClickUp,
281    /// Use an MCP client to query a Model Context Protocol server
282    Mcp,
283    /// Use Perplexity AI-powered web search for indexing
284    Perplexity,
285    /// Use grep.app for searching code across GitHub repositories
286    GrepApp,
287    /// Use AI coding assistant session logs (Claude Code, OpenCode, Cursor, Aider, Codex)
288    AiAssistant,
289    /// Use Quickwit search engine for log and observability data indexing
290    Quickwit,
291}
292
293/// A haystack is a collection of documents that can be indexed and searched
294///
295/// One user can have multiple haystacks
296/// Each haystack is indexed using a specific service
297#[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    /// The location of the haystack - can be a filesystem path or URL
302    pub location: String,
303    /// The service used for indexing documents in the haystack
304    pub service: ServiceType,
305    /// When set to `true` the haystack is treated as read-only; documents found
306    /// inside will not be modified on disk by Terraphim (e.g. via the Novel
307    /// editor). Defaults to `false` for backwards-compatibility.
308    #[serde(default)]
309    pub read_only: bool,
310    /// When set to `true`, fetch the actual content of documents from URLs
311    /// instead of just indexing metadata. Useful for web-based haystacks.
312    /// Defaults to `false` for backwards-compatibility.
313    #[serde(default)]
314    pub fetch_content: bool,
315    /// The secret for connecting to an Atomic Server.
316    /// This field is only serialized for Atomic service haystacks.
317    #[serde(default)]
318    pub atomic_server_secret: Option<String>,
319    /// Extra parameters specific to the service type.
320    /// For Ripgrep: can include additional command-line arguments like filtering by tags.
321    /// For Atomic: can include additional API parameters.
322    #[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        // Determine how many fields to include based on service type
334        let mut field_count = 3; // location, service, read_only
335
336        // Include atomic_server_secret only for Atomic service and if it's present
337        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        // Include extra_parameters if not empty
344        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        // Only include atomic_server_secret for Atomic service
354        if include_atomic_secret {
355            state.serialize_field("atomic_server_secret", &self.atomic_server_secret)?;
356        }
357
358        // Include extra_parameters if not empty
359        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    /// Create a new Haystack with extra parameters
369    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    /// Create a new Haystack with atomic server secret
381    pub fn with_atomic_secret(mut self, secret: Option<String>) -> Self {
382        // Only set secret for Atomic service
383        if self.service == ServiceType::Atomic {
384            self.atomic_server_secret = secret;
385        }
386        self
387    }
388
389    /// Add extra parameters to the haystack
390    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    /// Add a single extra parameter
399    pub fn with_extra_parameter(mut self, key: String, value: String) -> Self {
400        self.extra_parameters.insert(key, value);
401        self
402    }
403
404    /// Get a reference to extra parameters for this service type
405    pub fn get_extra_parameters(&self) -> &std::collections::HashMap<String, String> {
406        &self.extra_parameters
407    }
408}
409
410/// A knowledge graph is the collection of documents which were indexed
411/// using a specific service
412#[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    /// automata path refering to the published automata and can be online url or local file with pre-build automata
417    #[schemars(with = "Option<String>")]
418    pub automata_path: Option<AutomataPath>,
419    /// Knowlege graph can be re-build from local files, for example Markdown files
420    pub knowledge_graph_local: Option<KnowledgeGraphLocal>,
421    pub public: bool,
422    pub publish: bool,
423}
424/// check KG set correctly
425impl 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/// Builder, which allows to create a new `Config`
439///
440/// The first role added will be set as the default role.
441/// This can be changed by calling `default_role` with the role name.
442///
443/// # Example
444///
445/// ```rs
446/// use terraphim_config::ConfigBuilder;
447///
448/// let config = ConfigBuilder::new()
449///    .global_shortcut("Ctrl+X")
450///    .with_role("Default", role)
451///    .with_role("Engineer", role)
452///    .with_role("System Operator", role)
453///    .default_role("Default")
454///    .build();
455/// ```
456#[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    /// Create a new `ConfigBuilder`
466    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        // Add Default role with basic functionality
492        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        // Add Terraphim Engineer role with knowledge graph
507        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        // Add Rust Engineer role with QueryRs
533        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        // Set Terraphim Engineer as default and selected role
548        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        // mind where cargo run is triggered from
559        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        // Remove the automata_path - let it be built from local KG files during startup
653        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, // Set to None so it builds from local KG files during startup
678                    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    /// Start from an existing config
714    ///
715    /// This is useful when you want to start from an setup and modify some
716    /// fields
717    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    /// Set the global shortcut for the config
730    pub fn global_shortcut(mut self, global_shortcut: &str) -> Self {
731        self.config.global_shortcut = global_shortcut.to_string();
732        self
733    }
734
735    /// Add a new role to the config
736    pub fn add_role(mut self, role_name: &str, role: Role) -> Self {
737        let role_name = RoleName::new(role_name);
738        // Set to default role if this is the first role
739        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    /// Set the default role for the config
748    pub fn default_role(mut self, default_role: &str) -> Result<Self> {
749        let default_role = RoleName::new(default_role);
750        // Check if the role exists
751        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    /// Build the config
763    pub fn build(self) -> Result<Config> {
764        // Make sure that we have at least one role
765        // if self.config.roles.is_empty() {
766        //     return Err(TerraphimConfigError::NoRoles);
767        // }
768
769        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/// The Terraphim config is the main configuration for terraphim
789///
790/// It contains the global shortcut, roles, and the default role
791#[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    /// Identifier for the config
796    pub id: ConfigId,
797    /// Global shortcut for activating terraphim desktop
798    pub global_shortcut: String,
799    /// User roles with their respective settings
800    #[schemars(skip)]
801    pub roles: AHashMap<RoleName, Role>,
802    /// The default role to use if no role is specified
803    pub default_role: RoleName,
804    pub selected_role: RoleName,
805}
806
807impl Config {
808    fn empty() -> Self {
809        Self {
810            id: ConfigId::Server, // Default to Server
811            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        // Key is not used because we use the `id` field
829        Config::empty()
830    }
831
832    /// Save to a single profile
833    async fn save_to_one(&self, profile_name: &str) -> PersistenceResult<()> {
834        self.save_to_profile(profile_name).await?;
835        Ok(())
836    }
837
838    // Saves to all profiles
839    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    /// Load key from the fastest operator
846    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    /// returns ulid as key + .json
854    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/// ConfigState for the Terraphim (Actor)
866/// Config state can be updated using the API or Atomic Server
867///
868/// Holds the Terraphim Config and the RoleGraphs
869#[derive(Debug, Clone)]
870pub struct ConfigState {
871    /// Terraphim Config
872    pub config: Arc<Mutex<Config>>,
873    /// RoleGraphs
874    pub roles: AHashMap<RoleName, RoleGraphSync>,
875}
876
877impl ConfigState {
878    /// Create a new ConfigState
879    ///
880    /// For each role in a config, initialize a rolegraph
881    /// and add it to the config state
882    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                        // Try to load from automata path first
897                        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                        // If automata_path is None, but a local KG is defined, build it now
910                        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    /// Get the default role from the config
951    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    /// Get a role from the config
962    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    /// Insert document into all rolegraphs
968    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    /// Search documents in rolegraph index using matching Knowledge Graph
979    /// If knowledge graph isn't defined for the role, RoleGraph isn't build for the role
980    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        //FIXME: breaks here for ripgrep, means KB based search is triggered before KG build
993        let role = match self.roles.get(role_name) {
994            Some(role) => role.lock().await,
995            None => {
996                // Handle the None case, e.g., return an empty vector since the function expects Vec<IndexedDocument>
997                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            // Use multi-term search with logical operators
1006            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            // Use single-term search (backward compatibility)
1034            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        // Force in-memory persistence to avoid external/backing store locks in CI
1075        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        // Force in-memory persistence to avoid external/backing store locks in CI
1085        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                // Acceptable in CI: no s3 profile available when using memory-only init
1098            }
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                // This is expected in test environment where S3 data doesn't exist
1115            }
1116        }
1117    }
1118
1119    #[tokio::test]
1120    async fn test_save_one_memory() {
1121        // Force in-memory persistence to avoid external/backing store locks in CI
1122        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        // Ensure that the toml is valid
1134        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        // Create a new role by building a new config
1248        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    ///test to create config with different id - server, desktop, embedded
1260    #[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    /// repeat the test with server and embedded
1279    #[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        // Test ${HOME} expansion
1344        let result = expand_path("${HOME}/.terraphim");
1345        assert_eq!(result, home.join(".terraphim"));
1346
1347        // Test $HOME expansion
1348        let result = expand_path("$HOME/.terraphim");
1349        assert_eq!(result, home.join(".terraphim"));
1350
1351        // Test ~ expansion
1352        let result = expand_path("~/.terraphim");
1353        assert_eq!(result, home.join(".terraphim"));
1354
1355        // Test nested ${VAR:-default} with ${HOME}
1356        let result = expand_path("${TERRAPHIM_DATA_PATH:-${HOME}/.terraphim}");
1357        assert_eq!(result, home.join(".terraphim"));
1358
1359        // Test when env var is set
1360        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}