use crate::client::ClickUpClient;
use crate::error::{ClickUpError, Result};
use chrono::{Datelike, Utc};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
const FUZZY_THRESHOLD: f64 = 0.70;
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")),
}
}
#[allow(dead_code)]
const MIN_HISTORICAL_CONFIDENCE: f64 = 0.5;
#[derive(Debug, Clone)]
pub struct FolderSearchResult {
pub folder_id: String,
pub folder_name: String,
pub list_id: Option<String>,
pub list_name: Option<String>,
pub confidence: f64,
pub search_method: SearchMethod,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchMethod {
ExactMatch, FuzzyMatch, SemanticMatch, HistoricalMatch, NotFound, }
#[derive(Debug, Deserialize)]
struct ClickUpFoldersResponse {
folders: Vec<ClickUpFolder>,
}
#[derive(Debug, Deserialize, Clone)]
struct ClickUpFolder {
#[serde(deserialize_with = "deserialize_id_flexible")]
id: String,
name: String,
#[allow(dead_code)]
lists: Option<Vec<ClickUpList>>,
}
#[derive(Debug, Deserialize, Clone)]
struct ClickUpList {
#[serde(deserialize_with = "deserialize_id_flexible")]
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
}
#[derive(Debug, Deserialize)]
struct ClickUpTasksResponse {
tasks: Vec<ClickUpTask>,
}
#[derive(Debug, Deserialize)]
struct ClickUpTask {
#[serde(deserialize_with = "deserialize_id_flexible")]
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: Option<String>,
#[allow(dead_code)]
folder: Option<ClickUpTaskFolder>,
#[allow(dead_code)]
list: Option<ClickUpTaskList>,
#[allow(dead_code)]
custom_fields: Option<Vec<ClickUpCustomField>>,
}
#[derive(Debug, Deserialize)]
struct ClickUpTaskFolder {
#[serde(deserialize_with = "deserialize_id_flexible")]
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
}
#[derive(Debug, Deserialize)]
struct ClickUpTaskList {
#[allow(dead_code)]
#[serde(deserialize_with = "deserialize_id_flexible")]
id: String,
#[allow(dead_code)]
name: String,
}
#[derive(Debug, Deserialize)]
pub struct ClickUpCustomField {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
value: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct SmartFolderFinder {
client: ClickUpClient,
workspace_id: String,
cache: HashMap<String, FolderSearchResult>,
}
impl SmartFolderFinder {
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_folder_for_client(
&mut self,
client_name: &str,
) -> Result<Option<FolderSearchResult>> {
let normalized_name = Self::normalize_name(client_name);
tracing::info!(
"🔍 SmartFolderFinder: Buscando folder para '{}'",
client_name
);
if let Some(cached) = self.cache.get(&normalized_name) {
tracing::info!("✅ Encontrado em cache: folder_id={}", cached.folder_id);
return Ok(Some(cached.clone()));
}
match self.search_folders_via_api(&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 API, tentando busca histórica...");
}
Err(e) => {
tracing::warn!(
"⚠️ Erro na busca via API: {}, tentando busca histórica...",
e
);
}
}
match self.search_historical_tasks(&normalized_name).await {
Ok(Some(result)) => {
self.cache.insert(normalized_name.clone(), result.clone());
return Ok(Some(result));
}
Ok(None) => {
tracing::warn!(
"⚠️ Cliente '{}' não encontrado (nem API, nem histórico)",
client_name
);
}
Err(e) => {
tracing::error!("❌ Erro na busca histórica: {}", e);
}
}
Ok(None)
}
pub async fn find_folder_by_extracted_value(
&mut self,
extracted_value: &str,
) -> Result<Option<FolderSearchResult>> {
tracing::info!(
"🔍 SmartFolderFinder: Buscando folder usando valor extraído: '{}'",
extracted_value
);
self.find_folder_for_client(extracted_value).await
}
pub fn extract_custom_field_value(
custom_fields: &[ClickUpCustomField],
target_field_id: &str,
) -> Option<String> {
for field in custom_fields {
if field.id == target_field_id {
if let Some(value) = &field.value {
match value {
serde_json::Value::String(s) => return Some(s.clone()),
serde_json::Value::Number(n) => return Some(n.to_string()),
serde_json::Value::Bool(b) => return Some(b.to_string()),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
return Some(value.to_string());
}
serde_json::Value::Null => return None,
}
}
}
}
None
}
pub async fn find_folder_from_task_field(
&mut self,
custom_fields: Option<&Vec<ClickUpCustomField>>,
field_id: &str,
) -> Result<Option<FolderSearchResult>> {
tracing::info!(
"🔍 Extraindo valor do campo '{}' para busca de folder",
field_id
);
if let Some(fields) = custom_fields {
if let Some(extracted_value) = Self::extract_custom_field_value(fields, field_id) {
tracing::info!("✅ Valor extraído: '{}'", extracted_value);
return self.find_folder_by_extracted_value(&extracted_value).await;
}
}
tracing::warn!("⚠️ Campo '{}' não encontrado ou vazio", field_id);
Ok(None)
}
async fn search_folders_via_api(
&self,
normalized_client: &str,
) -> Result<Option<FolderSearchResult>> {
tracing::info!("📡 Buscando folders via API do ClickUp...");
let endpoint = format!("/team/{}/space", self.workspace_id);
let spaces: serde_json::Value = self.client.get_json(&endpoint).await?;
let spaces_array = spaces["spaces"].as_array().ok_or_else(|| {
ClickUpError::ValidationError("Campo 'spaces' não é array".to_string())
})?;
let mut all_folders = Vec::new();
for space in spaces_array {
let space_id = space["id"]
.as_str()
.ok_or_else(|| ClickUpError::ValidationError("Space sem ID".to_string()))?;
match self.fetch_folders_from_space(space_id).await {
Ok(folders) => all_folders.extend(folders),
Err(e) => {
tracing::warn!("⚠️ Erro ao buscar folders do space {}: {}", space_id, e);
}
}
}
tracing::info!("📁 Total de folders encontrados: {}", all_folders.len());
self.find_best_folder_match(normalized_client, &all_folders)
.await
}
async fn fetch_folders_from_space(&self, space_id: &str) -> Result<Vec<ClickUpFolder>> {
let endpoint = format!("/space/{}/folder", space_id);
let folders_response: ClickUpFoldersResponse = self.client.get_json(&endpoint).await?;
Ok(folders_response.folders)
}
async fn find_best_folder_match(
&self,
normalized_client: &str,
folders: &[ClickUpFolder],
) -> Result<Option<FolderSearchResult>> {
let mut best_match: Option<(ClickUpFolder, f64, SearchMethod)> = None;
for folder in folders {
let normalized_folder = Self::normalize_name(&folder.name);
if normalized_folder == normalized_client {
tracing::info!("✅ Match exato: '{}'", folder.name);
best_match = Some((folder.clone(), 1.0, SearchMethod::ExactMatch));
break;
}
let similarity = strsim::jaro_winkler(normalized_client, &normalized_folder);
tracing::debug!(
" Comparando: '{}' vs '{}' → score: {:.3}",
normalized_client,
normalized_folder,
similarity
);
if similarity >= FUZZY_THRESHOLD {
if let Some((_, best_score, _)) = &best_match {
if similarity > *best_score {
best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
}
} else {
best_match = Some((folder.clone(), similarity, SearchMethod::FuzzyMatch));
}
}
if best_match.is_none()
|| best_match
.as_ref()
.map(|(_, score, _)| *score)
.unwrap_or(0.0)
< 0.90
{
let client_tokens = Self::extract_name_tokens(normalized_client);
let folder_tokens = Self::extract_name_tokens(&normalized_folder);
if !client_tokens.is_empty() && !folder_tokens.is_empty() {
let matching_tokens = client_tokens
.iter()
.filter(|ct| {
folder_tokens
.iter()
.any(|ft| strsim::jaro_winkler(ct, ft) >= 0.90)
})
.count();
let token_score = matching_tokens as f64
/ client_tokens.len().max(folder_tokens.len()) as f64;
if token_score >= 0.60 {
tracing::debug!(
" Token match: {}/{} tokens → score: {:.3}",
matching_tokens,
client_tokens.len().max(folder_tokens.len()),
token_score
);
if let Some((_, best_score, _)) = &best_match {
if token_score > *best_score {
best_match =
Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
}
} else {
best_match =
Some((folder.clone(), token_score, SearchMethod::FuzzyMatch));
}
}
}
}
}
if let Some((folder, score, method)) = best_match {
let (list_id, list_name) = self.find_or_create_current_month_list(&folder.id).await?;
Ok(Some(FolderSearchResult {
folder_id: folder.id,
folder_name: folder.name,
list_id: Some(list_id),
list_name: Some(list_name),
confidence: score,
search_method: method,
}))
} else {
Ok(None)
}
}
async fn search_historical_tasks(
&self,
normalized_client: &str,
) -> Result<Option<FolderSearchResult>> {
tracing::info!(
"🕐 Buscando tarefas históricas para cliente = '{}' (funcionalidade descontinuada)",
normalized_client
);
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()
);
tracing::warn!(
"⚠️ Busca histórica descontinuada - campo 'Cliente Solicitante' removido do sistema"
);
Ok(None)
}
fn get_month_name_pt(&self, date: chrono::DateTime<Utc>) -> String {
let month = date.month();
let year = date.year();
let month_pt = match month {
1 => "JANEIRO",
2 => "FEVEREIRO",
3 => "MARÇO",
4 => "ABRIL",
5 => "MAIO",
6 => "JUNHO",
7 => "JULHO",
8 => "AGOSTO",
9 => "SETEMBRO",
10 => "OUTUBRO",
11 => "NOVEMBRO",
12 => "DEZEMBRO",
_ => "DESCONHECIDO",
};
format!("{} {}", month_pt, year)
}
async fn find_or_create_current_month_list(&self, folder_id: &str) -> Result<(String, String)> {
let now = Utc::now();
let month_name_pt = self.get_month_name_pt(now); let month_number = now.month();
tracing::info!("📅 Buscando lista do mês atual: '{}'", month_name_pt);
let endpoint = format!("/folder/{}", folder_id);
let folder: serde_json::Value = self.client.get_json(&endpoint).await?;
let months_pt = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
];
let current_month_pt = months_pt[(month_number - 1) as usize];
let year_str = now.year().to_string();
if let Some(lists) = folder["lists"].as_array() {
for list in lists {
if let Some(name) = list["name"].as_str() {
let name_lower = name.to_lowercase();
if (name_lower.contains(current_month_pt)
|| name_lower.contains(&now.format("%B").to_string().to_lowercase()))
&& name_lower.contains(&year_str)
{
let list_id = list["id"].as_str().ok_or_else(|| {
ClickUpError::ValidationError("Lista sem ID".to_string())
})?;
tracing::info!("✅ Lista do mês encontrada: {} (id: {})", name, list_id);
return Ok((list_id.to_string(), name.to_string()));
}
}
}
}
tracing::info!("📝 Criando lista do mês: '{}'", month_name_pt);
self.create_list(folder_id, &month_name_pt).await
}
async fn create_list(&self, folder_id: &str, list_name: &str) -> Result<(String, String)> {
let endpoint = format!("/folder/{}/list", folder_id);
let payload = serde_json::json!({
"name": list_name,
"content": format!("Lista criada automaticamente para {}", list_name),
});
let list: serde_json::Value = self.client.post_json(&endpoint, &payload).await?;
let list_id = list["id"]
.as_str()
.ok_or_else(|| ClickUpError::ValidationError("Lista criada sem ID".to_string()))?;
tracing::info!(
"✅ Lista criada com sucesso: {} (id: {})",
list_name,
list_id
);
tracing::debug!("⏳ Aguardando 2s para custom fields serem configurados...");
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
Ok((list_id.to_string(), list_name.to_string()))
}
pub fn normalize_name(name: &str) -> String {
use deunicode::deunicode;
let normalized = name.replace(['/', '\\', '|', '-'], " ");
deunicode(&normalized)
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
fn extract_name_tokens(name: &str) -> Vec<String> {
Self::normalize_name(name)
.split_whitespace()
.filter(|token| token.len() > 2) .map(|s| s.to_string())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_name() {
assert_eq!(
SmartFolderFinder::normalize_name("Raphaela Spielberg"),
"raphaela spielberg"
);
assert_eq!(
SmartFolderFinder::normalize_name("José Muritiba (123)"),
"jose muritiba 123"
);
assert_eq!(
SmartFolderFinder::normalize_name("Gabriel Benarros!!!"),
"gabriel benarros"
);
assert_eq!(
SmartFolderFinder::normalize_name("Breno / Leticia"),
"breno leticia"
);
assert_eq!(
SmartFolderFinder::normalize_name("Leticia e Breno"),
"leticia e breno"
);
assert_eq!(
SmartFolderFinder::normalize_name("Carlos | Pedro"),
"carlos pedro"
);
assert_eq!(SmartFolderFinder::normalize_name("Ana-Paula"), "ana paula");
}
#[test]
fn test_extract_name_tokens() {
let tokens = SmartFolderFinder::extract_name_tokens("Breno / Leticia");
assert_eq!(tokens, vec!["breno", "leticia"]);
let tokens2 = SmartFolderFinder::extract_name_tokens("Leticia e Breno");
assert_eq!(tokens2, vec!["leticia", "breno"]);
let tokens3 = SmartFolderFinder::extract_name_tokens("José de Oliveira");
assert_eq!(tokens3, vec!["jose", "oliveira"]); }
#[test]
fn test_extract_custom_field_value() {
use serde_json::Value;
let custom_fields = vec![
ClickUpCustomField {
id: "info_1".to_string(),
value: Some(Value::String("Valor Info 1".to_string())),
},
ClickUpCustomField {
id: "info_2".to_string(),
value: Some(Value::String("João Silva".to_string())),
},
ClickUpCustomField {
id: "priority".to_string(),
value: Some(Value::Number(serde_json::Number::from(3))),
},
ClickUpCustomField {
id: "active".to_string(),
value: Some(Value::Bool(true)),
},
ClickUpCustomField {
id: "empty_field".to_string(),
value: None,
},
];
assert_eq!(
SmartFolderFinder::extract_custom_field_value(&custom_fields, "info_2"),
Some("João Silva".to_string())
);
assert_eq!(
SmartFolderFinder::extract_custom_field_value(&custom_fields, "priority"),
Some("3".to_string())
);
assert_eq!(
SmartFolderFinder::extract_custom_field_value(&custom_fields, "active"),
Some("true".to_string())
);
assert_eq!(
SmartFolderFinder::extract_custom_field_value(&custom_fields, "inexistente"),
None
);
assert_eq!(
SmartFolderFinder::extract_custom_field_value(&custom_fields, "empty_field"),
None
);
}
}