use crate::client::ClickUpClient;
use crate::error::Result;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
const FUZZY_THRESHOLD: f64 = 0.70;
#[derive(Debug, Clone)]
pub struct AssigneeSearchResult {
pub user_id: String,
pub username: String,
pub email: Option<String>,
pub confidence: f64,
pub search_method: SearchMethod,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchMethod {
ExactMatch, FuzzyMatch, HistoricalMatch, NotFound, }
fn deserialize_id_flexible<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Deserialize};
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => Ok(s),
Value::Number(n) => Ok(n.to_string()),
_ => Err(de::Error::custom("id must be string or number")),
}
}
#[derive(Debug, Deserialize, Clone)]
struct ClickUpUser {
#[serde(deserialize_with = "deserialize_id_flexible")]
id: String,
username: String,
email: Option<String>,
#[allow(dead_code)]
#[serde(default)]
color: Option<String>,
#[allow(dead_code)]
#[serde(default, rename = "profilePicture")]
profile_picture: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClickUpTeamResponse {
team: ClickUpTeamData,
}
#[derive(Debug, Deserialize)]
struct ClickUpTeamData {
members: Vec<ClickUpMember>,
}
#[derive(Debug, Deserialize)]
struct ClickUpMember {
user: ClickUpUser,
}
#[derive(Debug, Deserialize)]
struct ClickUpTasksResponse {
tasks: Vec<ClickUpTask>,
}
#[derive(Debug, Deserialize)]
struct ClickUpTask {
#[serde(deserialize_with = "deserialize_id_flexible")]
#[allow(dead_code)]
id: String,
assignees: Vec<ClickUpUser>,
}
pub struct SmartAssigneeFinder {
client: ClickUpClient,
workspace_id: String,
cache: HashMap<String, AssigneeSearchResult>,
}
impl SmartAssigneeFinder {
pub fn new(client: ClickUpClient, workspace_id: String) -> Self {
Self {
client,
workspace_id,
cache: HashMap::new(),
}
}
pub fn from_token(api_token: String, workspace_id: String) -> Result<Self> {
let client = ClickUpClient::new(api_token)?;
Ok(Self::new(client, workspace_id))
}
pub async fn find_assignee_by_name(
&mut self,
responsavel_nome: &str,
) -> Result<Option<AssigneeSearchResult>> {
let normalized_name = Self::normalize_name(responsavel_nome);
tracing::info!(
"🔍 SmartAssigneeFinder: Buscando assignee para '{}'",
responsavel_nome
);
if let Some(cached) = self.cache.get(&normalized_name) {
tracing::info!("✅ Encontrado em cache: user_id={}", cached.user_id);
return Ok(Some(cached.clone()));
}
match self.search_team_members(&normalized_name).await {
Ok(Some(result)) => {
self.cache.insert(normalized_name.clone(), result.clone());
return Ok(Some(result));
}
Ok(None) => {
tracing::info!(
"⚠️ Não encontrado via Team Members API, tentando busca histórica..."
);
}
Err(e) => {
tracing::warn!(
"⚠️ Erro na busca via Team Members API: {}, tentando busca histórica...",
e
);
}
}
match self.search_historical_assignees(&normalized_name).await {
Ok(Some(result)) => {
self.cache.insert(normalized_name.clone(), result.clone());
return Ok(Some(result));
}
Ok(None) => {
tracing::warn!(
"⚠️ Responsável '{}' não encontrado (nem Team API, nem histórico)",
responsavel_nome
);
}
Err(e) => {
tracing::error!("❌ Erro na busca histórica de assignees: {}", e);
}
}
Ok(None)
}
async fn search_team_members(
&self,
normalized_name: &str,
) -> Result<Option<AssigneeSearchResult>> {
tracing::info!("👥 Buscando team members via API do ClickUp...");
let endpoint = format!("/team/{}", self.workspace_id);
let team_response: ClickUpTeamResponse = self.client.get_json(&endpoint).await?;
tracing::info!(
"👥 Total de membros encontrados: {}",
team_response.team.members.len()
);
self.find_best_assignee_match(normalized_name, &team_response.team.members)
.await
}
async fn find_best_assignee_match(
&self,
normalized_name: &str,
members: &[ClickUpMember],
) -> Result<Option<AssigneeSearchResult>> {
let mut best_match: Option<(ClickUpUser, f64, SearchMethod)> = None;
for member in members {
let user = &member.user;
let normalized_username = Self::normalize_name(&user.username);
if normalized_username == normalized_name {
tracing::info!("✅ Match exato: '{}'", user.username);
best_match = Some((user.clone(), 1.0, SearchMethod::ExactMatch));
break;
}
let similarity = strsim::jaro_winkler(normalized_name, &normalized_username);
tracing::debug!(
" Comparando: '{}' vs '{}' → score: {:.3}",
normalized_name,
normalized_username,
similarity
);
if similarity >= FUZZY_THRESHOLD {
if let Some((_, best_score, _)) = &best_match {
if similarity > *best_score {
best_match = Some((user.clone(), similarity, SearchMethod::FuzzyMatch));
}
} else {
best_match = Some((user.clone(), similarity, SearchMethod::FuzzyMatch));
}
}
}
if let Some((user, score, method)) = best_match {
Ok(Some(AssigneeSearchResult {
user_id: user.id,
username: user.username,
email: user.email,
confidence: score,
search_method: method,
}))
} else {
Ok(None)
}
}
async fn search_historical_assignees(
&self,
normalized_name: &str,
) -> Result<Option<AssigneeSearchResult>> {
tracing::info!("🕐 Buscando assignees em tarefas históricas...");
let endpoint = format!(
"/team/{}/task?archived=false&subtasks=false&include_closed=true",
self.workspace_id
);
let tasks_response: ClickUpTasksResponse = self.client.get_json(&endpoint).await?;
tracing::info!(
"📋 Total de tarefas encontradas: {}",
tasks_response.tasks.len()
);
let mut all_assignees: Vec<ClickUpUser> = Vec::new();
for task in tasks_response.tasks {
for assignee in task.assignees {
if !all_assignees.iter().any(|a| a.id == assignee.id) {
all_assignees.push(assignee);
}
}
}
tracing::info!(
"👥 Total de assignees únicos encontrados: {}",
all_assignees.len()
);
let mut best_match: Option<(ClickUpUser, f64)> = None;
for assignee in all_assignees {
let normalized_username = Self::normalize_name(&assignee.username);
let similarity = strsim::jaro_winkler(normalized_name, &normalized_username);
if similarity >= FUZZY_THRESHOLD {
if let Some((_, best_score)) = &best_match {
if similarity > *best_score {
best_match = Some((assignee, similarity));
}
} else {
best_match = Some((assignee, similarity));
}
}
}
if let Some((user, score)) = best_match {
tracing::info!(
"✅ Match histórico encontrado: {} (user_id: {}, score: {:.2})",
user.username,
user.id,
score
);
Ok(Some(AssigneeSearchResult {
user_id: user.id,
username: user.username,
email: user.email,
confidence: score,
search_method: SearchMethod::HistoricalMatch,
}))
} else {
tracing::warn!(
"⚠️ Nenhum assignee histórico encontrado para '{}'",
normalized_name
);
Ok(None)
}
}
pub fn normalize_name(name: &str) -> String {
use deunicode::deunicode;
deunicode(name)
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_name() {
assert_eq!(SmartAssigneeFinder::normalize_name("William"), "william");
assert_eq!(
SmartAssigneeFinder::normalize_name("Anne Souza"),
"anne souza"
);
assert_eq!(
SmartAssigneeFinder::normalize_name("Gabriel Moreno"),
"gabriel moreno"
);
assert_eq!(
SmartAssigneeFinder::normalize_name("WILLIAM DUARTE"),
"william duarte"
);
assert_eq!(SmartAssigneeFinder::normalize_name(" Anne "), "anne");
}
#[test]
fn test_fuzzy_matching_assignees() {
let test_cases = vec![
("William", "william", true),
("William", "Wiliam", true), ("Anne", "anne", true),
("Anne", "Ann", true), ("Gabriel Moreno", "gabriel moreno", true),
("Gabriel Moreno", "gabriel", true), ("William Duarte", "william duarte", true),
("Renata", "renata", true),
("Renata", "Renatta", true), ("William", "João", false), ];
for (original, digitado, should_match) in test_cases {
let original_norm = SmartAssigneeFinder::normalize_name(original);
let digitado_norm = SmartAssigneeFinder::normalize_name(digitado);
let similarity = strsim::jaro_winkler(&original_norm, &digitado_norm);
let matches = similarity >= 0.85;
println!(
"Comparando '{}' vs '{}' → score: {:.3} (match: {})",
original, digitado, similarity, matches
);
assert_eq!(
matches, should_match,
"Falha ao comparar '{}' vs '{}': score {:.3}",
original, digitado, similarity
);
}
}
}