use anyhow::Result;
use std::sync::Arc;
use terraphim_config::{Config, ConfigBuilder, ConfigId, ConfigState};
use terraphim_persistence::Persistable;
use terraphim_service::TerraphimService;
#[cfg(feature = "llm")]
use terraphim_service::llm::{ChatOptions, build_llm_from_role};
use terraphim_settings::{DeviceSettings, Error as DeviceSettingsError};
use terraphim_types::{Document, Layer, NormalizedTermValue, RoleName, SearchQuery, Thesaurus};
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct TuiService {
config_state: ConfigState,
service: Arc<Mutex<TerraphimService>>,
}
impl TuiService {
pub async fn new(config_path: Option<String>) -> Result<Self> {
terraphim_service::logging::init_logging(
terraphim_service::logging::detect_logging_config(),
);
log::info!("Initializing TUI service");
if let Some(ref path) = config_path {
log::info!("Loading config from --config flag: '{}'", path);
match Config::load_from_json_file(path) {
Ok(config) => {
return Self::from_config(config).await;
}
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to load config from '{}': {:?}",
path,
e
));
}
}
}
let device_settings = match DeviceSettings::load_from_env_and_file(None) {
Ok(settings) => settings,
Err(DeviceSettingsError::IoError(err))
if err.kind() == std::io::ErrorKind::NotFound =>
{
log::warn!(
"Device settings not found ({}); using embedded defaults",
err
);
DeviceSettings::default_embedded()
}
Err(err) => {
log::error!("Failed to load device settings: {err:?}");
return Err(err.into());
}
};
log::debug!("Device settings: {:?}", device_settings);
if let Some(ref role_config_path) = device_settings.role_config {
log::info!("Found role_config in settings.toml: '{}'", role_config_path);
return Self::load_with_role_config(role_config_path, &device_settings).await;
}
log::debug!("No role_config specified, using persistence/embedded defaults");
let config = match ConfigBuilder::new_with_id(ConfigId::Embedded).build() {
Ok(mut config) => match config.load().await {
Ok(config) => {
log::debug!("Loaded existing embedded configuration from persistence");
config
}
Err(_) => {
log::debug!("No saved config found, using default embedded");
return Self::new_with_embedded_defaults().await;
}
},
Err(e) => {
log::warn!("Failed to build config: {:?}, using default", e);
return Self::new_with_embedded_defaults().await;
}
};
Self::from_config(config).await
}
async fn load_with_role_config(
role_config_path: &str,
device_settings: &DeviceSettings,
) -> Result<Self> {
if let Ok(mut empty_config) = ConfigBuilder::new_with_id(ConfigId::Embedded).build() {
if let Ok(persisted) = empty_config.load().await {
if !persisted.roles.is_empty() {
log::info!(
"Loaded {} role(s) from persistence (role_config bootstrap already done)",
persisted.roles.len()
);
return Self::from_config(persisted).await;
}
}
}
log::info!(
"No persisted config found, bootstrapping from role_config: '{}'",
role_config_path
);
match Config::load_from_json_file(role_config_path) {
Ok(mut config) => {
if let Some(ref default_role) = device_settings.default_role {
let role_name = RoleName::new(default_role);
if config.roles.contains_key(&role_name) {
log::info!(
"Setting selected role to '{}' from settings.toml default_role",
default_role
);
config.selected_role = role_name.clone();
config.default_role = role_name;
} else {
log::warn!(
"default_role '{}' not found in role_config; available: {:?}",
default_role,
config
.roles
.keys()
.map(|k| k.to_string())
.collect::<Vec<_>>()
);
}
}
if let Err(e) = config.save().await {
log::warn!("Failed to save bootstrapped config to persistence: {:?}", e);
}
Self::from_config(config).await
}
Err(e) => {
log::error!(
"Failed to load role_config '{}': {:?}. Falling back to embedded defaults.",
role_config_path,
e
);
Self::new_with_embedded_defaults().await
}
}
}
pub async fn new_with_embedded_defaults() -> Result<Self> {
let config = ConfigBuilder::new_with_id(ConfigId::Embedded)
.build_default_embedded()
.build()?;
Self::from_config(config).await
}
async fn from_config(mut config: Config) -> Result<Self> {
let config_state = ConfigState::new(&mut config).await?;
let service = TerraphimService::new(config_state.clone());
Ok(Self {
config_state,
service: Arc::new(Mutex::new(service)),
})
}
pub async fn get_config(&self) -> terraphim_config::Config {
let config = self.config_state.config.lock().await;
config.clone()
}
pub async fn get_selected_role(&self) -> RoleName {
let config = self.config_state.config.lock().await;
config.selected_role.clone()
}
pub async fn update_selected_role(
&self,
role_name: RoleName,
) -> Result<terraphim_config::Config> {
let service = self.service.lock().await;
Ok(service.update_selected_role(role_name).await?)
}
pub async fn list_roles_with_info(&self) -> Vec<(String, Option<String>)> {
let config = self.config_state.config.lock().await;
config
.roles
.iter()
.map(|(name, role)| (name.to_string(), role.shortname.clone()))
.collect()
}
pub async fn find_role_by_name_or_shortname(&self, query: &str) -> Option<RoleName> {
let config = self.config_state.config.lock().await;
let query_lower = query.to_lowercase();
for (name, _role) in config.roles.iter() {
if name.to_string().to_lowercase() == query_lower {
return Some(name.clone());
}
}
for (name, role) in config.roles.iter() {
if let Some(ref shortname) = role.shortname {
if shortname.to_lowercase() == query_lower {
return Some(name.clone());
}
}
}
None
}
pub async fn resolve_role(&self, role: Option<&str>) -> Result<RoleName> {
match role {
Some(r) => self
.find_role_by_name_or_shortname(r)
.await
.ok_or_else(|| anyhow::anyhow!("Role '{}' not found in config", r)),
None => Ok(self.get_selected_role().await),
}
}
pub async fn search_with_role(
&self,
search_term: &str,
role: &RoleName,
limit: Option<usize>,
) -> Result<Vec<Document>> {
let query = SearchQuery {
search_term: NormalizedTermValue::from(search_term),
search_terms: None,
operator: None,
skip: Some(0),
limit,
role: Some(role.clone()),
layer: Layer::default(),
include_pinned: false,
};
let mut service = self.service.lock().await;
Ok(service.search(&query).await?)
}
pub async fn search_with_query(&self, query: &SearchQuery) -> Result<Vec<Document>> {
let mut service = self.service.lock().await;
Ok(service.search(query).await?)
}
pub async fn get_thesaurus(&self, role_name: &RoleName) -> Result<Thesaurus> {
let mut service = self.service.lock().await;
Ok(service.ensure_thesaurus_loaded(role_name).await?)
}
pub async fn get_role_graph_top_k(
&self,
role_name: &RoleName,
top_k: usize,
) -> Result<Vec<String>> {
log::info!("Getting top {} concepts for role {}", top_k, role_name);
if let Some(rolegraph_sync) = self.config_state.roles.get(role_name) {
let rolegraph = rolegraph_sync.lock().await;
let mut nodes: Vec<_> = rolegraph.nodes_map().iter().collect();
nodes.sort_by(|a, b| b.1.rank.cmp(&a.1.rank));
let top_concepts: Vec<String> = nodes
.into_iter()
.take(top_k)
.filter_map(|(node_id, _node)| {
rolegraph
.ac_reverse_nterm
.get(node_id)
.map(|term| term.to_string())
})
.collect();
log::debug!(
"Found {} concepts for role {} (requested {})",
top_concepts.len(),
role_name,
top_k
);
Ok(top_concepts)
} else {
log::warn!("Role graph not found for role {}", role_name);
Ok(Vec::new())
}
}
#[cfg(feature = "llm")]
pub async fn chat(
&self,
role_name: &RoleName,
prompt: &str,
_model: Option<String>,
) -> Result<String> {
let config = self.config_state.config.lock().await;
let role = config
.roles
.get(role_name)
.ok_or_else(|| anyhow::anyhow!("Role '{}' not found in configuration", role_name))?;
let llm_client = build_llm_from_role(role).ok_or_else(|| {
anyhow::anyhow!(
"No LLM configured for role '{}'. Add llm_provider, ollama_model, or llm_model to role's extra config.",
role_name
)
})?;
log::info!(
"Using LLM provider: {} for role: {}",
llm_client.name(),
role_name
);
let messages = vec![serde_json::json!({
"role": "user",
"content": prompt
})];
let opts = ChatOptions {
max_tokens: Some(1024),
temperature: Some(0.7),
};
let response = llm_client
.chat_completion(messages, opts)
.await
.map_err(|e| anyhow::anyhow!("LLM chat error: {}", e))?;
Ok(response)
}
pub async fn extract_paragraphs(
&self,
role_name: &RoleName,
text: &str,
exclude_term: bool,
) -> Result<Vec<(String, String)>> {
let thesaurus = self.get_thesaurus(role_name).await?;
let results = terraphim_automata::matcher::extract_paragraphs_from_automata(
text,
thesaurus,
!exclude_term, )?;
let string_results = results
.into_iter()
.map(|(matched, paragraph)| (matched.normalized_term.value.to_string(), paragraph))
.collect();
Ok(string_results)
}
#[cfg_attr(not(feature = "repl-mcp"), allow(dead_code))]
pub async fn autocomplete(
&self,
role_name: &RoleName,
query: &str,
limit: Option<usize>,
) -> Result<Vec<terraphim_automata::AutocompleteResult>> {
let thesaurus = self.get_thesaurus(role_name).await?;
let config = Some(terraphim_automata::AutocompleteConfig {
max_results: limit.unwrap_or(10),
min_prefix_length: 1,
case_sensitive: false,
});
let index = terraphim_automata::build_autocomplete_index(thesaurus, config)?;
Ok(terraphim_automata::autocomplete_search(
&index, query, limit,
)?)
}
pub async fn find_matches(
&self,
role_name: &RoleName,
text: &str,
) -> Result<Vec<terraphim_automata::Matched>> {
let thesaurus = self.get_thesaurus(role_name).await?;
Ok(terraphim_automata::find_matches(text, thesaurus, true)?)
}
#[cfg_attr(not(feature = "repl-mcp"), allow(dead_code))]
pub async fn replace_matches(
&self,
role_name: &RoleName,
text: &str,
link_type: terraphim_automata::LinkType,
) -> Result<String> {
let thesaurus = self.get_thesaurus(role_name).await?;
let result = terraphim_automata::replace_matches(text, thesaurus, link_type)?;
Ok(String::from_utf8(result).unwrap_or_else(|_| text.to_string()))
}
#[cfg(feature = "llm")]
pub async fn summarize(&self, role_name: &RoleName, content: &str) -> Result<String> {
let prompt = format!("Please summarize the following content:\n\n{}", content);
self.chat(role_name, &prompt, None).await
}
pub async fn save_config(&self) -> Result<()> {
let config = self.config_state.config.lock().await;
config.save().await?;
Ok(())
}
pub async fn reload_from_json(&self, path: &str) -> Result<usize> {
let new_config = Config::load_from_json_file(path)?;
let role_count = new_config.roles.len();
{
let mut config = self.config_state.config.lock().await;
*config = new_config;
}
self.save_config().await?;
Ok(role_count)
}
pub async fn check_connectivity(
&self,
role_name: &RoleName,
text: &str,
) -> Result<ConnectivityResult> {
let rolegraph_sync = self
.config_state
.roles
.get(role_name)
.ok_or_else(|| anyhow::anyhow!("RoleGraph not loaded for role '{}'", role_name))?;
let rolegraph = rolegraph_sync.lock().await;
let matched_node_ids = rolegraph.find_matching_node_ids(text);
if matched_node_ids.is_empty() {
return Ok(ConnectivityResult {
connected: true, matched_terms: vec![],
message: format!(
"No terms from role '{}' knowledge graph found in the provided text.",
role_name
),
});
}
let matched_terms: Vec<String> = matched_node_ids
.iter()
.filter_map(|node_id| {
rolegraph
.ac_reverse_nterm
.get(node_id)
.map(|nterm| nterm.to_string())
})
.collect();
let is_connected = rolegraph.is_all_terms_connected_by_path(text);
let message = if is_connected {
"All matched terms are connected by a single path in the knowledge graph.".to_string()
} else {
"The matched terms are NOT all connected by a single path.".to_string()
};
Ok(ConnectivityResult {
connected: is_connected,
matched_terms,
message,
})
}
pub async fn fuzzy_suggest(
&self,
role_name: &RoleName,
query: &str,
threshold: f64,
limit: Option<usize>,
) -> Result<Vec<FuzzySuggestion>> {
let thesaurus = self.get_thesaurus(role_name).await?;
let config = Some(terraphim_automata::AutocompleteConfig {
max_results: limit.unwrap_or(10),
min_prefix_length: 1,
case_sensitive: false,
});
let index = terraphim_automata::build_autocomplete_index(thesaurus, config)?;
let results =
terraphim_automata::fuzzy_autocomplete_search(&index, query, threshold, limit)?;
Ok(results
.into_iter()
.map(|r| FuzzySuggestion {
term: r.term,
similarity: r.score,
})
.collect())
}
pub async fn validate_checklist(
&self,
role_name: &RoleName,
checklist_name: &str,
text: &str,
) -> Result<ChecklistResult> {
let checklists = std::collections::HashMap::from([
(
"code_review",
vec![
"tests",
"test",
"testing",
"unit test",
"integration test",
"documentation",
"docs",
"comments",
"error handling",
"exception handling",
"security",
"security check",
"performance",
"optimization",
],
),
(
"security",
vec![
"authentication",
"auth",
"login",
"authorization",
"access control",
"permissions",
"input validation",
"sanitization",
"encryption",
"encrypted",
"ssl",
"tls",
"logging",
"audit log",
],
),
]);
let checklist_terms = checklists.get(checklist_name).ok_or_else(|| {
anyhow::anyhow!(
"Unknown checklist '{}'. Available: {:?}",
checklist_name,
checklists.keys().collect::<Vec<_>>()
)
})?;
let matches = self.find_matches(role_name, text).await?;
let matched_terms: std::collections::HashSet<String> =
matches.iter().map(|m| m.term.to_lowercase()).collect();
let categories = vec![
(
"tests",
vec!["tests", "test", "testing", "unit test", "integration test"],
),
("documentation", vec!["documentation", "docs", "comments"]),
(
"error_handling",
vec!["error handling", "exception handling"],
),
("security", vec!["security", "security check"]),
("performance", vec!["performance", "optimization"]),
("authentication", vec!["authentication", "auth", "login"]),
(
"authorization",
vec!["authorization", "access control", "permissions"],
),
("input_validation", vec!["input validation", "sanitization"]),
("encryption", vec!["encryption", "encrypted", "ssl", "tls"]),
("logging", vec!["logging", "audit log"]),
];
let relevant_categories: Vec<_> = categories
.iter()
.filter(|(_, terms)| terms.iter().any(|t| checklist_terms.contains(t)))
.collect();
let mut satisfied = Vec::new();
let mut missing = Vec::new();
for (category, terms) in &relevant_categories {
let found = terms
.iter()
.any(|t| matched_terms.contains(&t.to_lowercase()));
if found {
satisfied.push(category.to_string());
} else {
missing.push(category.to_string());
}
}
let total_items = satisfied.len() + missing.len();
let passed = missing.is_empty();
Ok(ChecklistResult {
checklist_name: checklist_name.to_string(),
passed,
total_items,
satisfied,
missing,
})
}
pub async fn add_role(&self, role: terraphim_config::Role) -> Result<()> {
{
let mut config = self.config_state.config.lock().await;
let role_name = role.name.clone();
config.roles.insert(role_name.clone(), role);
log::info!("Added role '{}' to configuration", role_name);
}
self.save_config().await?;
Ok(())
}
pub async fn set_role(&self, role: terraphim_config::Role) -> Result<()> {
{
let mut config = self.config_state.config.lock().await;
let role_name = role.name.clone();
config.roles.clear();
config.roles.insert(role_name.clone(), role);
config.selected_role = role_name.clone();
log::info!(
"Set configuration to role '{}' (cleared other roles)",
role_name
);
}
self.save_config().await?;
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConnectivityResult {
pub connected: bool,
pub matched_terms: Vec<String>,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FuzzySuggestion {
pub term: String,
pub similarity: f64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChecklistResult {
pub checklist_name: String,
pub passed: bool,
pub total_items: usize,
pub satisfied: Vec<String>,
pub missing: Vec<String>,
}