use ahash::AHashSet;
use terraphim_config::{Config, ConfigState, ServiceType};
use terraphim_rolegraph::RoleGraph;
use terraphim_types::RoleName;
pub const JMAP_MISSING_TOKEN_PENALTY: i64 = 1;
fn score_distinct_concepts(rg: &RoleGraph, query: &str) -> usize {
let ids = rg.find_matching_node_ids_with_fallback(query, false);
let unique: AHashSet<u64> = ids.into_iter().collect();
unique.len()
}
#[derive(Debug, Clone)]
pub struct AutoRouteContext {
pub selected_role: Option<RoleName>,
pub jmap_token_present: bool,
}
impl AutoRouteContext {
pub fn from_env(selected_role: Option<RoleName>) -> Self {
let jmap_token_present = std::env::var("JMAP_ACCESS_TOKEN")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
Self {
selected_role,
jmap_token_present,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoRouteReason {
ScoredWinner,
TieBrokenBySelectedRole,
TieBrokenAlphabetically,
ZeroMatchSelectedRole,
ZeroMatchDefault,
}
#[derive(Debug, Clone)]
pub struct AutoRouteResult {
pub role: RoleName,
pub score: i64,
pub candidates: Vec<(RoleName, i64)>,
pub reason: AutoRouteReason,
}
pub async fn auto_select_role(
query: &str,
config: &Config,
state: &ConfigState,
ctx: &AutoRouteContext,
) -> AutoRouteResult {
let mut scored: Vec<(RoleName, i64)> = Vec::with_capacity(state.roles.len());
for (role_name, rg_sync) in state.roles.iter() {
let rg = rg_sync.lock().await;
let raw_score: i64 = score_distinct_concepts(&rg, query) as i64;
let has_jmap = config
.roles
.get(role_name)
.map(|r| r.haystacks.iter().any(|h| h.service == ServiceType::Jmap))
.unwrap_or(false);
let final_score: i64 = if has_jmap && !ctx.jmap_token_present {
raw_score.saturating_sub(JMAP_MISSING_TOKEN_PENALTY)
} else {
raw_score
};
scored.push((role_name.clone(), final_score));
}
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.original.cmp(&b.0.original)));
if scored.is_empty() {
let fallback = config
.roles
.keys()
.min_by(|a, b| a.original.cmp(&b.original))
.cloned()
.unwrap_or_else(|| RoleName::from("Default"));
return AutoRouteResult {
role: fallback,
score: 0,
candidates: Vec::new(),
reason: AutoRouteReason::ZeroMatchDefault,
};
}
let top_score = scored[0].1;
if top_score == 0 {
if let Some(sel) = ctx.selected_role.as_ref() {
if config.roles.contains_key(sel) {
return AutoRouteResult {
role: sel.clone(),
score: 0,
candidates: scored,
reason: AutoRouteReason::ZeroMatchSelectedRole,
};
}
}
let default_role = RoleName::from("Default");
let chosen = if config.roles.contains_key(&default_role) {
default_role
} else {
scored[0].0.clone()
};
return AutoRouteResult {
role: chosen,
score: 0,
candidates: scored,
reason: AutoRouteReason::ZeroMatchDefault,
};
}
let tied: Vec<&RoleName> = scored
.iter()
.filter(|(_, s)| *s == top_score)
.map(|(n, _)| n)
.collect();
if tied.len() == 1 {
return AutoRouteResult {
role: scored[0].0.clone(),
score: top_score,
candidates: scored,
reason: AutoRouteReason::ScoredWinner,
};
}
if let Some(sel) = ctx.selected_role.as_ref() {
if tied.contains(&sel) {
return AutoRouteResult {
role: sel.clone(),
score: top_score,
candidates: scored,
reason: AutoRouteReason::TieBrokenBySelectedRole,
};
}
}
AutoRouteResult {
role: scored[0].0.clone(),
score: top_score,
candidates: scored,
reason: AutoRouteReason::TieBrokenAlphabetically,
}
}