use terraphim_config::ConfigState;
use terraphim_persistence::Persistable;
use terraphim_types::SearchQuery;
mod document;
mod score;
mod search;
mod summary;
mod thesaurus;
pub mod auto_route;
pub use auto_route::{
AutoRouteContext, AutoRouteReason, AutoRouteResult, JMAP_MISSING_TOKEN_PENALTY,
auto_select_role,
};
#[cfg(feature = "openrouter")]
pub mod openrouter;
pub mod llm;
pub mod llm_proxy;
pub mod http_client;
pub mod logging;
pub mod conversation_service;
pub mod rate_limiter;
pub mod summarization_manager;
pub mod summarization_queue;
pub mod summarization_worker;
pub mod error;
pub mod context;
#[cfg(test)]
mod context_tests;
pub(crate) fn normalize_filename_to_id(filename: &str) -> String {
let re = regex::Regex::new(r"[^a-zA-Z0-9]+").expect("Failed to create regex");
re.replace_all(filename, "").to_lowercase()
}
#[derive(thiserror::Error, Debug)]
pub enum ServiceError {
#[error("Middleware error: {0}")]
Middleware(#[from] terraphim_middleware::Error),
#[error("OpenDal error: {0}")]
OpenDal(Box<opendal::Error>),
#[error("Persistence error: {0}")]
Persistence(#[from] terraphim_persistence::Error),
#[error("Config error: {0}")]
Config(String),
#[cfg(feature = "openrouter")]
#[error("OpenRouter error: {0}")]
OpenRouter(#[from] crate::openrouter::OpenRouterError),
#[error("Common error: {0}")]
Common(#[from] crate::error::CommonError),
}
impl From<opendal::Error> for ServiceError {
fn from(err: opendal::Error) -> Self {
ServiceError::OpenDal(Box::new(err))
}
}
impl crate::error::TerraphimError for ServiceError {
fn category(&self) -> crate::error::ErrorCategory {
use crate::error::ErrorCategory;
match self {
ServiceError::Middleware(_) => ErrorCategory::Integration,
ServiceError::OpenDal(_) => ErrorCategory::Storage,
ServiceError::Persistence(_) => ErrorCategory::Storage,
ServiceError::Config(_) => ErrorCategory::Configuration,
#[cfg(feature = "openrouter")]
ServiceError::OpenRouter(_) => ErrorCategory::Integration,
ServiceError::Common(err) => err.category(),
}
}
fn is_recoverable(&self) -> bool {
match self {
ServiceError::Middleware(_) => true,
ServiceError::OpenDal(_) => false,
ServiceError::Persistence(_) => false,
ServiceError::Config(_) => false,
#[cfg(feature = "openrouter")]
ServiceError::OpenRouter(_) => true,
ServiceError::Common(err) => err.is_recoverable(),
}
}
}
pub type Result<T> = std::result::Result<T, ServiceError>;
pub struct TerraphimService {
config_state: ConfigState,
}
impl TerraphimService {
pub fn new(config_state: ConfigState) -> Self {
Self { config_state }
}
pub async fn fetch_config(&self) -> terraphim_config::Config {
let current_config = self.config_state.config.lock().await;
current_config.clone()
}
#[cfg(test)]
pub async fn get_role(
&self,
role_name: &terraphim_types::RoleName,
) -> Result<terraphim_config::Role> {
let config = self.config_state.config.lock().await;
config
.roles
.get(role_name)
.cloned()
.ok_or_else(|| ServiceError::Config(format!("Role '{}' not found", role_name)))
}
pub async fn update_config(
&self,
config: terraphim_config::Config,
) -> Result<terraphim_config::Config> {
{
let mut current_config = self.config_state.config.lock().await;
*current_config = config.clone();
}
config.save().await?;
log::info!("Config updated");
Ok(config)
}
pub async fn update_selected_role(
&self,
role_name: terraphim_types::RoleName,
) -> Result<terraphim_config::Config> {
let snapshot = {
let mut current_config = self.config_state.config.lock().await;
if !current_config.roles.contains_key(&role_name) {
return Err(ServiceError::Config(format!(
"Role `{}` not found in config",
role_name
)));
}
current_config.selected_role = role_name.clone();
current_config.clone()
};
let snapshot_for_save = snapshot.clone();
let role_for_log = role_name.clone();
tokio::spawn(async move {
if let Err(e) = snapshot_for_save.save().await {
log::warn!(
"background persist of selected_role={} failed: {}",
role_for_log,
e
);
}
});
if let Some(role) = snapshot.roles.get(&role_name) {
if role.terraphim_it {
log::info!(
"🎯 Selected role '{}' → terraphim_it: ENABLED (KG preprocessing will be applied)",
role_name
);
} else {
log::info!("🎯 Selected role '{}' → terraphim_it: DISABLED", role_name);
}
}
Ok(snapshot)
}
pub(crate) fn highlight_search_terms(content: &str, search_query: &SearchQuery) -> String {
let mut highlighted_content = content.to_string();
let terms = search_query.get_all_terms();
let mut sorted_terms: Vec<&str> = terms.iter().map(|t| t.as_str()).collect();
sorted_terms.sort_by_key(|term| std::cmp::Reverse(term.len()));
for term in sorted_terms {
if term.trim().is_empty() {
continue;
}
let escaped_term = regex::escape(term);
if let Ok(regex) = regex::RegexBuilder::new(&escaped_term)
.case_insensitive(true)
.build()
{
let highlight_open = "<mark class=\"search-highlight\">";
let highlight_close = "</mark>";
highlighted_content = regex
.replace_all(
&highlighted_content,
format!("{}{}{}", highlight_open, "$0", highlight_close),
)
.to_string();
}
}
highlighted_content
}
}
pub(crate) fn snippet_around(s: &str, marker: &str, before: usize, after: usize) -> String {
let Some(marker_byte) = s.find(marker) else {
return String::new();
};
let marker_char_index = s[..marker_byte].chars().count();
let total_chars = s.chars().count();
let start_char_index = marker_char_index.saturating_sub(before);
let end_char_index = (marker_char_index + marker.len() + after).min(total_chars);
if start_char_index >= end_char_index {
return String::new();
}
s.chars()
.skip(start_char_index)
.take(end_char_index - start_char_index)
.collect()
}
#[cfg(test)]
mod lib_tests;