use std::{path::PathBuf, sync::Arc};
use terraphim_automata::{
AutomataPath,
builder::{Logseq, ThesaurusBuilder},
load_thesaurus,
};
use terraphim_persistence::Persistable;
use terraphim_rolegraph::{RoleGraph, RoleGraphSync};
use terraphim_types::{
Document, IndexedDocument, KnowledgeGraphInputType, RelevanceFunction, RoleName, SearchQuery,
};
use terraphim_settings::DeviceSettings;
use ahash::AHashMap;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use tokio::sync::Mutex;
#[cfg(feature = "typescript")]
use tsify::Tsify;
use crate::llm_router::LlmRouterConfig;
pub mod llm_router;
pub type Result<T> = std::result::Result<T, TerraphimConfigError>;
use opendal::Result as OpendalResult;
type PersistenceResult<T> = std::result::Result<T, terraphim_persistence::Error>;
#[derive(Error, Debug)]
pub enum TerraphimConfigError {
#[error("Unable to load config")]
NotFound,
#[error("At least one role is required")]
NoRoles,
#[error("Profile error")]
Profile(String),
#[error("Persistence error")]
Persistence(Box<terraphim_persistence::Error>),
#[error("Serde JSON error")]
Json(#[from] serde_json::Error),
#[error("Cannot initialize tracing subscriber")]
TracingSubscriber(Box<dyn std::error::Error + Send + Sync>),
#[error("Pipe error")]
Pipe(#[from] terraphim_rolegraph::Error),
#[error("Automata error")]
Automata(#[from] terraphim_automata::TerraphimAutomataError),
#[error("Url error")]
Url(#[from] url::ParseError),
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("Config error")]
Config(String),
}
impl From<terraphim_persistence::Error> for TerraphimConfigError {
fn from(error: terraphim_persistence::Error) -> Self {
TerraphimConfigError::Persistence(Box::new(error))
}
}
pub fn expand_path(path: &str) -> PathBuf {
let mut result = path.to_string();
fn get_home_dir() -> Option<PathBuf> {
if let Some(home) = dirs::home_dir() {
return Some(home);
}
if let Ok(home) = std::env::var("HOME") {
return Some(PathBuf::from(home));
}
if let Ok(profile) = std::env::var("USERPROFILE") {
return Some(PathBuf::from(profile));
}
None
}
loop {
if let Some(start) = result.find("${") {
if let Some(colon_pos) = result[start..].find(":-") {
let colon_pos = start + colon_pos;
let var_name = &result[start + 2..colon_pos];
let after_colon = colon_pos + 2;
let mut depth = 1;
let mut end_pos = after_colon;
for (i, c) in result[after_colon..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end_pos = after_colon + i;
break;
}
}
_ => {}
}
}
if depth == 0 {
let default_value = &result[after_colon..end_pos];
let replacement =
std::env::var(var_name).unwrap_or_else(|_| default_value.to_string());
result = format!(
"{}{}{}",
&result[..start],
replacement,
&result[end_pos + 1..]
);
continue; }
}
}
break;
}
let re_braces = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
result = re_braces
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = &caps[1];
if var_name == "HOME" {
get_home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| format!("${{{}}}", var_name))
} else {
std::env::var(var_name).unwrap_or_else(|_| format!("${{{}}}", var_name))
}
})
.to_string();
let re_dollar = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
result = re_dollar
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = &caps[1];
if var_name == "HOME" {
get_home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| format!("${}", var_name))
} else {
std::env::var(var_name).unwrap_or_else(|_| format!("${}", var_name))
}
})
.to_string();
if result.starts_with('~') {
if let Some(home) = get_home_dir() {
result = result.replacen('~', &home.to_string_lossy(), 1);
}
}
PathBuf::from(result)
}
fn default_context_window() -> Option<u64> {
Some(32768)
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Default)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub struct Role {
pub shortname: Option<String>,
pub name: RoleName,
pub relevance_function: RelevanceFunction,
pub terraphim_it: bool,
pub theme: String,
pub kg: Option<KnowledgeGraph>,
pub haystacks: Vec<Haystack>,
#[serde(default)]
pub llm_enabled: bool,
#[serde(default)]
pub llm_api_key: Option<String>,
#[serde(default)]
pub llm_model: Option<String>,
#[serde(default)]
pub llm_auto_summarize: bool,
#[serde(default)]
pub llm_chat_enabled: bool,
#[serde(default)]
pub llm_chat_system_prompt: Option<String>,
#[serde(default)]
pub llm_chat_model: Option<String>,
#[serde(default = "default_context_window")]
pub llm_context_window: Option<u64>,
#[serde(flatten)]
#[schemars(skip)]
#[cfg_attr(feature = "typescript", tsify(type = "Record<string, unknown>"))]
pub extra: AHashMap<String, Value>,
#[serde(default)]
pub llm_router_enabled: bool,
#[serde(default)]
pub llm_router_config: Option<LlmRouterConfig>,
}
impl Role {
pub fn new(name: impl Into<RoleName>) -> Self {
Self {
shortname: None,
name: name.into(),
relevance_function: RelevanceFunction::TitleScorer,
terraphim_it: false,
theme: "default".to_string(),
kg: None,
haystacks: vec![],
llm_enabled: false,
llm_api_key: None,
llm_model: None,
llm_auto_summarize: false,
llm_chat_enabled: false,
llm_chat_system_prompt: None,
llm_chat_model: None,
llm_context_window: default_context_window(),
extra: AHashMap::new(),
llm_router_enabled: false,
llm_router_config: None,
}
}
pub fn has_llm_config(&self) -> bool {
self.llm_enabled && self.llm_api_key.is_some() && self.llm_model.is_some()
}
pub fn get_llm_model(&self) -> Option<&str> {
self.llm_model.as_deref()
}
}
use anyhow::Context;
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub enum ServiceType {
Ripgrep,
Atomic,
QueryRs,
ClickUp,
Mcp,
Perplexity,
GrepApp,
AiAssistant,
Quickwit,
Jmap,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub struct Haystack {
pub location: String,
pub service: ServiceType,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub fetch_content: bool,
#[serde(default)]
pub atomic_server_secret: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub extra_parameters: std::collections::HashMap<String, String>,
}
impl Serialize for Haystack {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut field_count = 3;
let include_atomic_secret =
self.service == ServiceType::Atomic && self.atomic_server_secret.is_some();
if include_atomic_secret {
field_count += 1;
}
if !self.extra_parameters.is_empty() {
field_count += 1;
}
let mut state = serializer.serialize_struct("Haystack", field_count)?;
state.serialize_field("location", &self.location)?;
state.serialize_field("service", &self.service)?;
state.serialize_field("read_only", &self.read_only)?;
if include_atomic_secret {
state.serialize_field("atomic_server_secret", &self.atomic_server_secret)?;
}
if !self.extra_parameters.is_empty() {
state.serialize_field("extra_parameters", &self.extra_parameters)?;
}
state.end()
}
}
impl Haystack {
pub fn new(location: String, service: ServiceType, read_only: bool) -> Self {
Self {
location,
service,
read_only,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}
}
pub fn with_atomic_secret(mut self, secret: Option<String>) -> Self {
if self.service == ServiceType::Atomic {
self.atomic_server_secret = secret;
}
self
}
pub fn with_extra_parameters(
mut self,
params: std::collections::HashMap<String, String>,
) -> Self {
self.extra_parameters = params;
self
}
pub fn with_extra_parameter(mut self, key: String, value: String) -> Self {
self.extra_parameters.insert(key, value);
self
}
pub fn get_extra_parameters(&self) -> &std::collections::HashMap<String, String> {
&self.extra_parameters
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub struct KnowledgeGraph {
#[schemars(with = "Option<String>")]
pub automata_path: Option<AutomataPath>,
pub knowledge_graph_local: Option<KnowledgeGraphLocal>,
pub public: bool,
pub publish: bool,
}
impl KnowledgeGraph {
pub fn is_set(&self) -> bool {
self.automata_path.is_some() || self.knowledge_graph_local.is_some()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub struct KnowledgeGraphLocal {
pub input_type: KnowledgeGraphInputType,
pub path: PathBuf,
}
#[derive(Debug)]
pub struct ConfigBuilder {
config: Config,
device_settings: DeviceSettings,
#[allow(dead_code)]
settings_path: PathBuf,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self {
config: Config::empty(),
device_settings: DeviceSettings::new(),
settings_path: PathBuf::new(),
}
}
pub fn new_with_id(id: ConfigId) -> Self {
let device_settings = match id {
ConfigId::Embedded => DeviceSettings::default_embedded(),
_ => DeviceSettings::new(),
};
Self {
config: Config {
id,
..Config::empty()
},
device_settings,
settings_path: PathBuf::new(),
}
}
pub fn build_default_embedded(mut self) -> Self {
self.config.id = ConfigId::Embedded;
let mut default_role = Role::new("Default");
default_role.shortname = Some("Default".to_string());
default_role.theme = "spacelab".to_string();
default_role.extra.insert(
"llm_provider".to_string(),
Value::String("ollama".to_string()),
);
default_role.extra.insert(
"llm_model".to_string(),
Value::String("llama3.2:3b".to_string()),
);
default_role.haystacks = vec![Haystack {
location: "docs/src".to_string(),
service: ServiceType::Ripgrep,
read_only: true,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
self = self.add_role("Default", default_role);
let mut terraphim_role = Role::new("Terraphim Engineer");
terraphim_role.shortname = Some("TerraEng".to_string());
terraphim_role.relevance_function = RelevanceFunction::TerraphimGraph;
terraphim_role.terraphim_it = true;
terraphim_role.theme = "lumen".to_string();
terraphim_role.extra.insert(
"llm_provider".to_string(),
Value::String("ollama".to_string()),
);
terraphim_role.extra.insert(
"llm_model".to_string(),
Value::String("llama3.2:3b".to_string()),
);
terraphim_role.kg = Some(KnowledgeGraph {
automata_path: None,
knowledge_graph_local: Some(KnowledgeGraphLocal {
input_type: KnowledgeGraphInputType::Markdown,
path: self.get_default_data_path().join("kg"),
}),
public: true,
publish: true,
});
terraphim_role.haystacks = vec![Haystack {
location: "docs/src".to_string(),
service: ServiceType::Ripgrep,
read_only: true,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
self = self.add_role("Terraphim Engineer", terraphim_role);
let mut rust_engineer_role = Role::new("Rust Engineer");
rust_engineer_role.shortname = Some("rust-engineer".to_string());
rust_engineer_role.theme = "cosmo".to_string();
rust_engineer_role.extra.insert(
"llm_provider".to_string(),
Value::String("ollama".to_string()),
);
rust_engineer_role.extra.insert(
"llm_model".to_string(),
Value::String("qwen2.5-coder:latest".to_string()),
);
rust_engineer_role.haystacks = vec![Haystack {
location: "https://query.rs".to_string(),
service: ServiceType::QueryRs,
read_only: true,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
self = self.add_role("Rust Engineer", rust_engineer_role);
self.config.default_role = RoleName::new("Terraphim Engineer");
self.config.selected_role = RoleName::new("Terraphim Engineer");
self
}
pub fn get_default_data_path(&self) -> PathBuf {
expand_path(&self.device_settings.default_data_path)
}
pub fn build_default_server(mut self) -> Self {
self.config.id = ConfigId::Server;
let cwd = std::env::current_dir()
.context("Failed to get current directory")
.unwrap();
log::info!("Current working directory: {}", cwd.display());
let system_operator_haystack = if cwd.ends_with("terraphim_server") {
cwd.join("fixtures/haystack/")
} else {
cwd.join("terraphim_server/fixtures/haystack/")
};
log::debug!("system_operator_haystack: {:?}", system_operator_haystack);
let automata_test_path = if cwd.ends_with("terraphim_server") {
cwd.join("fixtures/term_to_id.json")
} else {
cwd.join("terraphim_server/fixtures/term_to_id.json")
};
log::debug!("Test automata_test_path {:?}", automata_test_path);
let automata_remote = AutomataPath::from_remote(
"https://staging-storage.terraphim.io/thesaurus_Default.json",
)
.unwrap();
log::info!("Automata remote URL: {automata_remote}");
self.global_shortcut("Ctrl+X")
.add_role("Default", {
let mut default_role = Role::new("Default");
default_role.shortname = Some("Default".to_string());
default_role.theme = "spacelab".to_string();
default_role.haystacks = vec![Haystack {
location: system_operator_haystack.to_string_lossy().to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
default_role
})
.add_role("Engineer", {
let mut engineer_role = Role::new("Engineer");
engineer_role.shortname = Some("Engineer".into());
engineer_role.relevance_function = RelevanceFunction::TerraphimGraph;
engineer_role.terraphim_it = true;
engineer_role.theme = "lumen".to_string();
engineer_role.kg = Some(KnowledgeGraph {
automata_path: Some(automata_remote.clone()),
knowledge_graph_local: Some(KnowledgeGraphLocal {
input_type: KnowledgeGraphInputType::Markdown,
path: system_operator_haystack.clone(),
}),
public: true,
publish: true,
});
engineer_role.haystacks = vec![Haystack {
location: system_operator_haystack.to_string_lossy().to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
engineer_role
})
.add_role("System Operator", {
let mut system_operator_role = Role::new("System Operator");
system_operator_role.shortname = Some("operator".to_string());
system_operator_role.relevance_function = RelevanceFunction::TerraphimGraph;
system_operator_role.terraphim_it = true;
system_operator_role.theme = "superhero".to_string();
system_operator_role.kg = Some(KnowledgeGraph {
automata_path: Some(automata_remote.clone()),
knowledge_graph_local: Some(KnowledgeGraphLocal {
input_type: KnowledgeGraphInputType::Markdown,
path: system_operator_haystack.clone(),
}),
public: true,
publish: true,
});
system_operator_role.haystacks = vec![Haystack {
location: system_operator_haystack.to_string_lossy().to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
system_operator_role
})
.default_role("Default")
.unwrap()
}
pub fn build_default_desktop(mut self) -> Self {
let default_data_path = self.get_default_data_path();
log::info!("Documents path: {:?}", default_data_path);
self.config.id = ConfigId::Desktop;
self.global_shortcut("Ctrl+X")
.add_role("Default", {
let mut default_role = Role::new("Default");
default_role.shortname = Some("Default".to_string());
default_role.theme = "spacelab".to_string();
default_role.haystacks = vec![Haystack {
location: default_data_path.to_string_lossy().to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
default_role
})
.add_role("Terraphim Engineer", {
let mut terraphim_engineer_role = Role::new("Terraphim Engineer");
terraphim_engineer_role.shortname = Some("TerraEng".to_string());
terraphim_engineer_role.relevance_function = RelevanceFunction::TerraphimGraph;
terraphim_engineer_role.terraphim_it = true;
terraphim_engineer_role.theme = "lumen".to_string();
terraphim_engineer_role.kg = Some(KnowledgeGraph {
automata_path: None, knowledge_graph_local: Some(KnowledgeGraphLocal {
input_type: KnowledgeGraphInputType::Markdown,
path: default_data_path.join("kg"),
}),
public: true,
publish: true,
});
terraphim_engineer_role.haystacks = vec![Haystack {
location: default_data_path.to_string_lossy().to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
terraphim_engineer_role
})
.add_role("Rust Engineer", {
let mut rust_engineer_role = Role::new("Rust Engineer");
rust_engineer_role.shortname = Some("rust-engineer".to_string());
rust_engineer_role.theme = "cosmo".to_string();
rust_engineer_role.haystacks = vec![Haystack {
location: "https://query.rs".to_string(),
service: ServiceType::QueryRs,
read_only: true,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
rust_engineer_role
})
.default_role("Terraphim Engineer")
.unwrap()
}
pub fn from_config(
config: Config,
device_settings: DeviceSettings,
settings_path: PathBuf,
) -> Self {
Self {
config,
device_settings,
settings_path,
}
}
pub fn global_shortcut(mut self, global_shortcut: &str) -> Self {
self.config.global_shortcut = global_shortcut.to_string();
self
}
pub fn add_role(mut self, role_name: &str, role: Role) -> Self {
let role_name = RoleName::new(role_name);
if self.config.roles.is_empty() {
self.config.default_role = role_name.clone();
}
self.config.roles.insert(role_name, role);
self
}
pub fn default_role(mut self, default_role: &str) -> Result<Self> {
let default_role = RoleName::new(default_role);
if !self.config.roles.contains_key(&default_role) {
return Err(TerraphimConfigError::Profile(format!(
"Role `{}` does not exist",
default_role
)));
}
self.config.default_role = default_role;
Ok(self)
}
pub fn build(self) -> Result<Config> {
Ok(self.config)
}
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub enum ConfigId {
Server,
Desktop,
Embedded,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[cfg_attr(feature = "typescript", tsify(into_wasm_abi, from_wasm_abi))]
pub struct Config {
pub id: ConfigId,
pub global_shortcut: String,
#[schemars(skip)]
pub roles: AHashMap<RoleName, Role>,
pub default_role: RoleName,
pub selected_role: RoleName,
}
impl Config {
fn empty() -> Self {
Self {
id: ConfigId::Server, global_shortcut: "Ctrl+X".to_string(),
roles: AHashMap::new(),
default_role: RoleName::new("Default"),
selected_role: RoleName::new("Default"),
}
}
pub fn load_from_json_file(path: &str) -> Result<Self> {
let expanded = expand_path(path);
log::info!("Loading role configuration from: {}", expanded.display());
let content = std::fs::read_to_string(&expanded).map_err(|e| {
log::error!(
"Failed to read role config file '{}' (expanded from '{}'): {}",
expanded.display(),
path,
e
);
TerraphimConfigError::Config(format!(
"Cannot read role config file '{}': {}",
expanded.display(),
e
))
})?;
let config: Config = serde_json::from_str(&content)?;
log::info!(
"Loaded {} role(s) from '{}': {:?}",
config.roles.len(),
expanded.display(),
config
.roles
.keys()
.map(|k| k.to_string())
.collect::<Vec<_>>()
);
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
Self::empty()
}
}
#[async_trait]
impl Persistable for Config {
fn new(_key: String) -> Self {
Config::empty()
}
async fn save_to_one(&self, profile_name: &str) -> PersistenceResult<()> {
self.save_to_profile(profile_name).await?;
Ok(())
}
async fn save(&self) -> PersistenceResult<()> {
let _op = &self.load_config().await?.1;
let _ = self.save_to_all().await?;
Ok(())
}
async fn load(&mut self) -> PersistenceResult<Self> {
let op = &self.load_config().await?.1;
let key = self.get_key();
let obj = self.load_from_operator(&key, op).await?;
Ok(obj)
}
fn get_key(&self) -> String {
match self.id {
ConfigId::Server => "server",
ConfigId::Desktop => "desktop",
ConfigId::Embedded => "embedded",
}
.to_string()
+ "_config.json"
}
}
#[derive(Debug, Clone)]
pub struct ConfigState {
pub config: Arc<Mutex<Config>>,
pub roles: AHashMap<RoleName, RoleGraphSync>,
}
impl ConfigState {
pub async fn new(config: &mut Config) -> Result<Self> {
let mut roles = AHashMap::new();
for (name, role) in &config.roles {
let role_name = name.clone();
log::info!("Creating role {}", role_name);
if role.relevance_function == RelevanceFunction::TerraphimGraph {
if let Some(kg) = &role.kg {
if let Some(automata_path) = &kg.automata_path {
log::info!(
"Role {} is configured correctly with automata_path",
role_name
);
log::info!("Loading Role `{}` - URL: {:?}", role_name, automata_path);
match load_thesaurus(automata_path).await {
Ok(thesaurus) => {
log::info!("Successfully loaded thesaurus from automata path");
let rolegraph =
RoleGraph::new(role_name.clone(), thesaurus).await?;
roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph));
}
Err(e) => {
log::warn!("Failed to load thesaurus from automata path: {:?}", e);
if let Some(kg_local) = &kg.knowledge_graph_local {
log::info!(
"Falling back to local KG for role {} at {:?}",
role_name,
kg_local.path
);
let logseq_builder = Logseq::default();
match logseq_builder
.build(
role_name.as_lowercase().to_string(),
kg_local.path.clone(),
)
.await
{
Ok(thesaurus) => {
log::info!(
"Successfully built thesaurus from local KG fallback for role {}",
role_name
);
let rolegraph =
RoleGraph::new(role_name.clone(), thesaurus)
.await?;
roles.insert(
role_name.clone(),
RoleGraphSync::from(rolegraph),
);
}
Err(e2) => {
log::error!(
"Failed to build thesaurus from local KG fallback for role {}: {:?}",
role_name,
e2
);
}
}
}
}
}
} else if let Some(kg_local) = &kg.knowledge_graph_local {
log::info!(
"Role {} has no automata_path, building thesaurus from local KG files at {:?}",
role_name,
kg_local.path
);
let logseq_builder = Logseq::default();
match logseq_builder
.build(role_name.as_lowercase().to_string(), kg_local.path.clone())
.await
{
Ok(thesaurus) => {
log::info!(
"Successfully built thesaurus from local KG for role {}",
role_name
);
let rolegraph =
RoleGraph::new(role_name.clone(), thesaurus).await?;
roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph));
}
Err(e) => {
log::error!(
"Failed to build thesaurus from local KG for role {}: {:?}",
role_name,
e
);
}
}
} else {
log::warn!(
"Role {} is configured for TerraphimGraph but has neither automata_path nor knowledge_graph_local defined.",
role_name
);
}
}
}
}
Ok(ConfigState {
config: Arc::new(Mutex::new(config.clone())),
roles,
})
}
pub async fn get_default_role(&self) -> RoleName {
let config = self.config.lock().await;
config.default_role.clone()
}
pub async fn get_selected_role(&self) -> RoleName {
let config = self.config.lock().await;
config.selected_role.clone()
}
pub async fn get_role(&self, role: &RoleName) -> Option<Role> {
let config = self.config.lock().await;
config.roles.get(role).cloned()
}
pub async fn add_to_roles(&mut self, document: &Document) -> OpendalResult<()> {
let id = document.id.clone();
for rolegraph_state in self.roles.values() {
let mut rolegraph = rolegraph_state.lock().await;
rolegraph.insert_document(&id, document.clone());
}
Ok(())
}
pub async fn search_indexed_documents(
&self,
search_query: &SearchQuery,
role: &Role,
) -> Vec<IndexedDocument> {
log::debug!("search_documents: {:?}", search_query);
log::debug!("Role for search_documents: {:#?}", role);
let role_name = &role.name;
log::debug!("Role name for searching {role_name}");
log::debug!("All roles defined {:?}", self.roles.clone().into_keys());
let role = match self.roles.get(role_name) {
Some(role) => role.lock().await,
None => {
log::error!(
"Role `{}` does not exist or RoleGraph isn't populated",
role_name
);
return Vec::new();
}
};
let documents = if search_query.is_multi_term_query() {
let all_terms: Vec<&str> = search_query
.get_all_terms()
.iter()
.map(|t| t.as_str())
.collect();
let operator = search_query.get_operator();
log::debug!(
"Performing multi-term search with {} terms using {:?} operator",
all_terms.len(),
operator
);
role.query_graph_with_operators(
&all_terms,
&operator,
search_query.skip,
search_query.limit,
)
.unwrap_or_else(|e| {
log::error!(
"Error while searching graph with operators for documents: {:?}",
e
);
vec![]
})
} else {
role.query_graph(
search_query.search_term.as_str(),
search_query.skip,
search_query.limit,
)
.unwrap_or_else(|e| {
log::error!("Error while searching graph for documents: {:?}", e);
vec![]
})
};
documents.into_iter().map(|(_id, doc)| doc).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempfile;
use terraphim_test_utils::EnvVarGuard;
use tokio::test;
#[test]
async fn test_write_config_to_json() {
let config = Config::empty();
let json_str = serde_json::to_string_pretty(&config).unwrap();
let mut tempfile = tempfile().unwrap();
tempfile.write_all(json_str.as_bytes()).unwrap();
}
#[test]
async fn test_get_key() {
let config = Config::empty();
serde_json::to_string_pretty(&config).unwrap();
assert!(config.get_key().ends_with(".json"));
}
#[tokio::test]
async fn test_save_all() {
terraphim_persistence::DeviceStorage::init_memory_only()
.await
.unwrap();
let config = Config::empty();
config.save().await.unwrap();
}
#[tokio::test]
async fn test_save_one_s3() {
terraphim_persistence::DeviceStorage::init_memory_only()
.await
.unwrap();
let config = Config::empty();
println!("{:#?}", config);
match config.save_to_one("s3").await {
Ok(_) => println!("Successfully saved to s3 (env provides s3 profile)"),
Err(e) => {
println!(
"Expected error saving to s3 in test environment without s3 profile: {:?}",
e
);
}
}
}
#[tokio::test]
async fn load_s3() {
let mut config = Config::empty();
match config.load().await {
Ok(loaded_config) => {
println!("Successfully loaded config: {:#?}", loaded_config);
}
Err(e) => {
println!(
"Expected error loading config (no S3 data in test environment): {:?}",
e
);
}
}
}
#[tokio::test]
async fn test_save_one_memory() {
let _ = terraphim_persistence::DeviceStorage::init_memory_only().await;
let config = Config::empty();
match config.save_to_one("memory").await {
Ok(_) => println!("Successfully saved to memory profile"),
Err(_) => {
config.save().await.unwrap();
}
}
}
#[test]
async fn test_write_config_to_toml() {
let config = Config::empty();
let toml = toml::to_string_pretty(&config).unwrap();
toml::from_str::<Config>(&toml).unwrap();
}
#[tokio::test]
async fn test_config_builder() {
let automata_remote = AutomataPath::from_remote(
"https://staging-storage.terraphim.io/thesaurus_Default.json",
)
.unwrap();
let config = ConfigBuilder::new()
.global_shortcut("Ctrl+X")
.add_role("Default", {
let mut default_role = Role::new("Default");
default_role.shortname = Some("Default".to_string());
default_role.theme = "spacelab".to_string();
default_role.haystacks = vec![Haystack {
location: "localsearch".to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
default_role
})
.add_role("Engineer", {
let mut engineer_role = Role::new("Engineer");
engineer_role.shortname = Some("Engineer".to_string());
engineer_role.theme = "lumen".to_string();
engineer_role.haystacks = vec![Haystack {
location: "localsearch".to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
engineer_role
})
.add_role("System Operator", {
let mut system_operator_role = Role::new("System Operator");
system_operator_role.shortname = Some("operator".to_string());
system_operator_role.relevance_function = RelevanceFunction::TerraphimGraph;
system_operator_role.terraphim_it = true;
system_operator_role.theme = "superhero".to_string();
system_operator_role.kg = Some(KnowledgeGraph {
automata_path: Some(automata_remote.clone()),
knowledge_graph_local: Some(KnowledgeGraphLocal {
input_type: KnowledgeGraphInputType::Markdown,
path: PathBuf::from("~/pkm"),
}),
public: true,
publish: true,
});
system_operator_role.haystacks = vec![Haystack {
location: "/tmp/system_operator/pages/".to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
system_operator_role
})
.default_role("Default")
.unwrap()
.build()
.unwrap();
assert_eq!(config.roles.len(), 3);
assert_eq!(config.default_role, RoleName::new("Default"));
}
#[test]
async fn test_update_global_shortcut() {
let config = ConfigBuilder::new()
.add_role("dummy", dummy_role())
.build()
.unwrap();
assert_eq!(config.global_shortcut, "Ctrl+X");
let device_settings = DeviceSettings::new();
let settings_path = PathBuf::from(".");
let new_config = ConfigBuilder::from_config(config, device_settings, settings_path)
.global_shortcut("Ctrl+/")
.build()
.unwrap();
assert_eq!(new_config.global_shortcut, "Ctrl+/");
}
fn dummy_role() -> Role {
let mut role = Role::new("Father");
role.shortname = Some("father".into());
role.theme = "lumen".to_string();
role.kg = Some(KnowledgeGraph {
automata_path: Some(AutomataPath::local_example()),
knowledge_graph_local: None,
public: true,
publish: true,
});
role.haystacks = vec![Haystack {
location: "localsearch".to_string(),
service: ServiceType::Ripgrep,
read_only: false,
fetch_content: false,
atomic_server_secret: None,
extra_parameters: std::collections::HashMap::new(),
}];
role
}
#[test]
async fn test_add_role() {
let config = ConfigBuilder::new()
.add_role("Father", dummy_role())
.build()
.unwrap();
assert!(config.roles.contains_key(&RoleName::new("Father")));
assert_eq!(config.roles.len(), 1);
assert_eq!(&config.default_role, &RoleName::new("Father"));
assert_eq!(config.roles[&RoleName::new("Father")], dummy_role());
}
#[tokio::test]
async fn test_config_with_id_desktop() {
let config = match ConfigBuilder::new_with_id(ConfigId::Desktop).build() {
Ok(mut config) => match config.load().await {
Ok(config) => config,
Err(e) => {
log::info!("Failed to load config: {:?}", e);
ConfigBuilder::new()
.build_default_desktop()
.build()
.unwrap()
}
},
Err(e) => panic!("Failed to build config: {:?}", e),
};
assert_eq!(config.id, ConfigId::Desktop);
}
#[tokio::test]
async fn test_config_with_id_server() {
let config = match ConfigBuilder::new_with_id(ConfigId::Server).build() {
Ok(mut local_config) => match local_config.load().await {
Ok(config) => config,
Err(e) => {
log::info!("Failed to load config: {:?}", e);
ConfigBuilder::new().build_default_server().build().unwrap()
}
},
Err(e) => panic!("Failed to build config: {:?}", e),
};
assert_eq!(config.id, ConfigId::Server);
}
#[tokio::test]
async fn test_config_with_id_embedded() {
let config = match ConfigBuilder::new_with_id(ConfigId::Embedded).build() {
Ok(mut config) => match config.load().await {
Ok(config) => config,
Err(e) => {
log::info!("Failed to load config: {:?}", e);
ConfigBuilder::new()
.build_default_embedded()
.build()
.unwrap()
}
},
Err(e) => panic!("Failed to build config: {:?}", e),
};
assert_eq!(config.id, ConfigId::Embedded);
}
#[tokio::test]
#[ignore]
async fn test_at_least_one_role() {
let config = ConfigBuilder::new().build();
assert!(config.is_err());
assert!(matches!(config.unwrap_err(), TerraphimConfigError::NoRoles));
}
#[tokio::test]
async fn test_json_serialization() {
let config = Config::default();
let json = serde_json::to_string_pretty(&config).unwrap();
log::debug!("Config: {:#?}", config);
assert!(!json.is_empty());
}
#[tokio::test]
async fn test_toml_serialization() {
let config = Config::default();
let toml = toml::to_string_pretty(&config).unwrap();
log::debug!("Config: {:#?}", config);
assert!(!toml.is_empty());
}
#[tokio::test]
async fn test_expand_path_home() {
let home = dirs::home_dir().expect("HOME should be set");
let home_str = home.to_string_lossy();
let result = expand_path("${HOME}/.terraphim");
assert_eq!(result, home.join(".terraphim"));
let result = expand_path("$HOME/.terraphim");
assert_eq!(result, home.join(".terraphim"));
let result = expand_path("~/.terraphim");
assert_eq!(result, home.join(".terraphim"));
let result = expand_path("${TERRAPHIM_DATA_PATH:-${HOME}/.terraphim}");
assert_eq!(result, home.join(".terraphim"));
let _guard = EnvVarGuard::set("TERRAPHIM_TEST_PATH", "/custom/path");
let result = expand_path("${TERRAPHIM_TEST_PATH:-${HOME}/.default}");
assert_eq!(result, PathBuf::from("/custom/path"));
drop(_guard);
println!("expand_path tests passed!");
println!("HOME = {}", home_str);
println!(
"${{HOME}}/.terraphim -> {:?}",
expand_path("${HOME}/.terraphim")
);
}
#[test]
async fn test_load_from_json_file_with_fixture() {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let fixture_path =
workspace_root.join("terraphim_server/default/terraphim_engineer_config.json");
let config = Config::load_from_json_file(fixture_path.to_str().unwrap()).unwrap();
assert!(
!config.roles.is_empty(),
"Config should have at least one role"
);
assert!(
config
.roles
.contains_key(&RoleName::new("Terraphim Engineer")),
"Config should contain Terraphim Engineer role"
);
}
#[test]
async fn test_load_from_json_file_not_found() {
let result = Config::load_from_json_file("/nonexistent/path/does_not_exist.json");
assert!(result.is_err(), "Should return error for missing file");
}
#[test]
async fn test_load_from_json_file_invalid_json() {
let mut tmpfile = tempfile().unwrap();
tmpfile.write_all(b"this is not json").unwrap();
let result = Config::load_from_json_file("/dev/null");
assert!(
result.is_err(),
"Should return error for empty/invalid JSON"
);
}
}