use crate::connector::{ConnectorRegistry, ConnectorStatus, ImportOptions};
use crate::model::{FileAccess, Session, SessionId};
use anyhow::Result;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct SessionService {
registry: ConnectorRegistry,
cache: Arc<RwLock<HashMap<SessionId, Session>>>,
auto_import: bool,
auto_import_attempted: Arc<RwLock<bool>>,
}
impl SessionService {
#[must_use]
pub fn new() -> Self {
Self {
registry: ConnectorRegistry::new(),
cache: Arc::new(RwLock::new(HashMap::new())),
auto_import: true,
auto_import_attempted: Arc::new(RwLock::new(false)),
}
}
#[must_use]
pub fn with_registry(registry: ConnectorRegistry) -> Self {
Self {
registry,
cache: Arc::new(RwLock::new(HashMap::new())),
auto_import: true,
auto_import_attempted: Arc::new(RwLock::new(false)),
}
}
pub fn disable_auto_import(&mut self) {
self.auto_import = false;
}
pub fn enable_auto_import(&mut self) {
self.auto_import = true;
}
#[must_use]
pub fn is_auto_import_enabled(&self) -> bool {
self.auto_import
}
async fn maybe_auto_import(&self) -> Result<()> {
if !self.auto_import {
return Ok(());
}
{
let attempted = self.auto_import_attempted.read().await;
if *attempted {
return Ok(());
}
}
let cache_empty = {
let cache = self.cache.read().await;
cache.is_empty()
};
if cache_empty {
tracing::info!("Cache empty, auto-importing sessions...");
let options = ImportOptions::new();
match self.import_all(&options).await {
Ok(sessions) => {
tracing::info!("Auto-imported {} sessions", sessions.len());
}
Err(e) => {
tracing::warn!("Auto-import failed: {}", e);
}
}
}
let mut attempted = self.auto_import_attempted.write().await;
*attempted = true;
Ok(())
}
#[must_use]
pub fn registry(&self) -> &ConnectorRegistry {
&self.registry
}
pub fn detect_sources(&self) -> Vec<SourceInfo> {
self.registry
.detect_all()
.into_iter()
.map(|(id, status)| {
let connector = self.registry.get(id);
SourceInfo {
id: id.to_string(),
name: connector.map(|c| c.display_name().to_string()),
status,
}
})
.collect()
}
pub async fn import_from(
&self,
source_id: &str,
options: &ImportOptions,
) -> Result<Vec<Session>> {
let connector = self
.registry
.get(source_id)
.ok_or_else(|| anyhow::anyhow!("Unknown source: {}", source_id))?;
let sessions = connector.import(options).await?;
let mut cache = self.cache.write().await;
for session in &sessions {
cache.insert(session.id.clone(), session.clone());
}
Ok(sessions)
}
pub async fn import_all(&self, options: &ImportOptions) -> Result<Vec<Session>> {
let sessions = self.registry.import_all(options).await?;
let mut cache = self.cache.write().await;
for session in &sessions {
cache.insert(session.id.clone(), session.clone());
}
Ok(sessions)
}
pub async fn list_sessions(&self) -> Vec<Session> {
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
cache.values().cloned().collect()
}
pub async fn get_session(&self, id: &SessionId) -> Option<Session> {
let cache = self.cache.read().await;
cache.get(id).cloned()
}
pub async fn search(&self, query: &str) -> Vec<Session> {
self.search_inner(query).await
}
#[cfg(feature = "enrichment")]
pub async fn search_with_thesaurus(
&self,
query: &str,
thesaurus: Option<terraphim_types::Thesaurus>,
) -> Vec<Session> {
self.search_inner_with_thesaurus(query, thesaurus).await
}
async fn search_inner(&self, query: &str) -> Vec<Session> {
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
let sessions: Vec<Session> = cache.values().cloned().collect();
drop(cache);
#[cfg(feature = "search-index")]
{
let scored = crate::search::search_sessions(&sessions, query);
scored.into_iter().map(|s| s.into_value()).collect()
}
#[cfg(not(feature = "search-index"))]
{
let query_lower = query.to_lowercase();
sessions
.into_iter()
.filter(|session| {
if let Some(title) = &session.title {
if title.to_lowercase().contains(&query_lower) {
return true;
}
}
if let Some(path) = &session.metadata.project_path {
if path.to_lowercase().contains(&query_lower) {
return true;
}
}
for msg in &session.messages {
if msg.content.to_lowercase().contains(&query_lower) {
return true;
}
}
false
})
.collect()
}
}
#[cfg(feature = "enrichment")]
async fn search_inner_with_thesaurus(
&self,
query: &str,
thesaurus: Option<terraphim_types::Thesaurus>,
) -> Vec<Session> {
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
let sessions: Vec<Session> = cache.values().cloned().collect();
drop(cache);
#[cfg(feature = "search-index")]
{
let scored = crate::search::search_sessions_hybrid(&sessions, query, thesaurus);
scored.into_iter().map(|s| s.into_value()).collect()
}
#[cfg(not(feature = "search-index"))]
{
let _ = thesaurus;
let query_lower = query.to_lowercase();
sessions
.into_iter()
.filter(|session| {
if let Some(title) = &session.title {
if title.to_lowercase().contains(&query_lower) {
return true;
}
}
if let Some(path) = &session.metadata.project_path {
if path.to_lowercase().contains(&query_lower) {
return true;
}
}
for msg in &session.messages {
if msg.content.to_lowercase().contains(&query_lower) {
return true;
}
}
false
})
.collect()
}
}
pub async fn sessions_by_source(&self, source: &str) -> Vec<Session> {
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
cache
.values()
.filter(|s| s.source == source)
.cloned()
.collect()
}
pub async fn session_count(&self) -> usize {
let cache = self.cache.read().await;
cache.len()
}
pub async fn clear_cache(&self) {
let mut cache = self.cache.write().await;
cache.clear();
}
pub async fn load_sessions(&self, sessions: Vec<Session>) {
let mut cache = self.cache.write().await;
for session in sessions {
cache.insert(session.id.clone(), session);
}
}
pub async fn statistics(&self) -> SessionStatistics {
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
let mut total_messages = 0;
let mut total_user_messages = 0;
let mut total_assistant_messages = 0;
let mut sources: HashMap<String, usize> = HashMap::new();
for session in cache.values() {
total_messages += session.message_count();
total_user_messages += session.user_message_count();
total_assistant_messages += session.assistant_message_count();
*sources.entry(session.source.clone()).or_default() += 1;
}
SessionStatistics {
total_sessions: cache.len(),
total_messages,
total_user_messages,
total_assistant_messages,
sessions_by_source: sources,
}
}
pub async fn extract_files(&self, session_id: &SessionId) -> Option<Vec<FileAccess>> {
let cache = self.cache.read().await;
cache
.get(session_id)
.map(|session| session.extract_file_accesses())
}
pub async fn sessions_by_file(&self, file_path: &str) -> Vec<Session> {
let cache = self.cache.read().await;
let path_lower = file_path.to_lowercase();
cache
.values()
.filter(|session| {
let accesses = session.extract_file_accesses();
accesses
.iter()
.any(|access| access.path.to_lowercase().contains(&path_lower))
})
.cloned()
.collect()
}
}
impl Default for SessionService {
fn default() -> Self {
Self::new()
}
}
impl Clone for SessionService {
fn clone(&self) -> Self {
Self {
registry: ConnectorRegistry::new(),
cache: Arc::new(RwLock::new(HashMap::new())),
auto_import: self.auto_import,
auto_import_attempted: Arc::new(RwLock::new(false)),
}
}
}
#[derive(Debug, Clone)]
pub struct SourceInfo {
pub id: String,
pub name: Option<String>,
pub status: ConnectorStatus,
}
impl SourceInfo {
pub fn is_available(&self) -> bool {
self.status.is_available()
}
}
#[derive(Debug, Clone, Default)]
pub struct SessionStatistics {
pub total_sessions: usize,
pub total_messages: usize,
pub total_user_messages: usize,
pub total_assistant_messages: usize,
pub sessions_by_source: HashMap<String, usize>,
}
#[cfg(feature = "enrichment")]
#[derive(Debug, Clone)]
pub struct SessionCluster {
pub id: usize,
pub sessions: Vec<Session>,
pub dominant_concepts: Vec<String>,
}
#[cfg(feature = "enrichment")]
fn jaccard_similarity(
a: &std::collections::HashSet<String>,
b: &std::collections::HashSet<String>,
) -> f64 {
let intersection = a.iter().filter(|k| b.contains(*k)).count();
let union = a.len() + b.len() - intersection;
if union == 0 {
0.0
} else {
intersection as f64 / union as f64
}
}
#[cfg(feature = "enrichment")]
fn average_cluster_similarity(
ca: &[usize],
cb: &[usize],
concept_sets: &[std::collections::HashSet<String>],
) -> f64 {
let mut total = 0.0_f64;
let mut count = 0_usize;
for &i in ca {
for &j in cb {
total += jaccard_similarity(&concept_sets[i], &concept_sets[j]);
count += 1;
}
}
if count == 0 {
0.0
} else {
total / count as f64
}
}
impl SessionService {
#[cfg(feature = "enrichment")]
pub async fn cluster_by_concepts(
&self,
k: Option<usize>,
min_sessions: Option<usize>,
) -> Vec<SessionCluster> {
use std::collections::{HashMap, HashSet};
if let Err(e) = self.maybe_auto_import().await {
tracing::warn!("Auto-import check failed: {}", e);
}
let cache = self.cache.read().await;
let sessions: Vec<Session> = cache.values().cloned().collect();
drop(cache);
if sessions.is_empty() {
return Vec::new();
}
let min_size = min_sessions.unwrap_or(1);
const THRESHOLD: f64 = 0.1;
let mut enriched_sessions: Vec<Session> = Vec::new();
let mut enriched_concepts: Vec<HashSet<String>> = Vec::new();
let mut unenriched: Vec<Session> = Vec::new();
for session in sessions {
if let Some(ref sc) = session.metadata.enrichment {
if !sc.concepts.is_empty() {
let concept_set: HashSet<String> = sc.concepts.keys().cloned().collect();
enriched_sessions.push(session);
enriched_concepts.push(concept_set);
continue;
}
}
unenriched.push(session);
}
let n = enriched_sessions.len();
let mut cluster_ids: Vec<Option<usize>> = vec![None; n];
let mut next_id = 0_usize;
for i in 0..n {
if cluster_ids[i].is_some() {
continue;
}
let cid = next_id;
next_id += 1;
cluster_ids[i] = Some(cid);
for j in (i + 1)..n {
if cluster_ids[j].is_some() {
continue;
}
let members: Vec<usize> =
(0..=i).filter(|&x| cluster_ids[x] == Some(cid)).collect();
let avg_sim: f64 = members
.iter()
.map(|&m| jaccard_similarity(&enriched_concepts[m], &enriched_concepts[j]))
.sum::<f64>()
/ members.len() as f64;
if avg_sim >= THRESHOLD {
cluster_ids[j] = Some(cid);
}
}
}
let mut cluster_map: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, cid) in cluster_ids.iter().enumerate() {
if let Some(c) = cid {
cluster_map.entry(*c).or_default().push(i);
}
}
let mut cluster_vec: Vec<Vec<usize>> = cluster_map.into_values().collect();
cluster_vec.sort_by_key(|c| std::cmp::Reverse(c.len()));
if let Some(max_k) = k {
while cluster_vec.len() > max_k && cluster_vec.len() >= 2 {
let nc = cluster_vec.len();
let mut best_sim = -1.0_f64;
let mut best = (0usize, 1usize);
for i in 0..nc {
for j in (i + 1)..nc {
let sim = average_cluster_similarity(
&cluster_vec[i],
&cluster_vec[j],
&enriched_concepts,
);
if sim > best_sim {
best_sim = sim;
best = (i, j);
}
}
}
let (a, b) = best;
let to_merge = cluster_vec.remove(b);
cluster_vec[a].extend(to_merge);
}
}
let mut result: Vec<SessionCluster> = cluster_vec
.into_iter()
.filter(|indices| indices.len() >= min_size)
.enumerate()
.map(|(cluster_idx, indices)| {
let cluster_sessions: Vec<Session> = indices
.iter()
.map(|&i| enriched_sessions[i].clone())
.collect();
let mut concept_counts: HashMap<String, usize> = HashMap::new();
for &i in &indices {
for concept in &enriched_concepts[i] {
*concept_counts.entry(concept.clone()).or_default() += 1;
}
}
let mut sorted: Vec<(String, usize)> = concept_counts.into_iter().collect();
sorted.sort_by_key(|item| std::cmp::Reverse(item.1));
let dominant: Vec<String> = sorted.into_iter().take(5).map(|(c, _)| c).collect();
SessionCluster {
id: cluster_idx + 1,
sessions: cluster_sessions,
dominant_concepts: dominant,
}
})
.collect();
if !unenriched.is_empty() && unenriched.len() >= min_size {
result.push(SessionCluster {
id: result.len() + 1,
sessions: unenriched,
dominant_concepts: vec!["(no enrichment data)".to_string()],
});
}
for (i, c) in result.iter_mut().enumerate() {
c.id = i + 1;
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_session_service_creation() {
let service = SessionService::new();
assert_eq!(service.session_count().await, 0);
}
#[tokio::test]
async fn test_detect_sources() {
let service = SessionService::new();
let sources = service.detect_sources();
assert!(!sources.is_empty());
assert!(sources.iter().any(|s| s.id == "claude-code-native"));
}
#[tokio::test]
async fn test_statistics_empty() {
let mut service = SessionService::new();
service.disable_auto_import();
let stats = service.statistics().await;
assert_eq!(stats.total_sessions, 0);
assert_eq!(stats.total_messages, 0);
}
fn make_test_session(id: &str, source: &str, messages: Vec<crate::model::Message>) -> Session {
Session {
id: id.to_string(),
source: source.to_string(),
external_id: id.to_string(),
title: Some(format!("Session {}", id)),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages,
metadata: crate::model::SessionMetadata::default(),
}
}
#[tokio::test]
async fn test_load_and_list_sessions() {
let service = SessionService::new();
let sessions = vec![
make_test_session("s1", "test", vec![]),
make_test_session("s2", "test", vec![]),
];
service.load_sessions(sessions).await;
let listed = service.list_sessions().await;
assert_eq!(listed.len(), 2);
assert_eq!(service.session_count().await, 2);
}
#[tokio::test]
async fn test_get_session_by_id() {
let service = SessionService::new();
let sessions = vec![make_test_session("s1", "test", vec![])];
service.load_sessions(sessions).await;
let found = service.get_session(&"s1".to_string()).await;
assert!(found.is_some());
assert_eq!(found.unwrap().id, "s1");
let not_found = service.get_session(&"nonexistent".to_string()).await;
assert!(not_found.is_none());
}
#[tokio::test]
async fn test_search_by_title() {
use crate::model::{Message, MessageRole};
let service = SessionService::new();
let s1 = {
let mut s = make_test_session(
"s1",
"test",
vec![Message::text(
0,
MessageRole::User,
"Rust async programming help",
)],
);
s.title = Some("Rust async programming".to_string());
s
};
let s2 = {
let mut s = make_test_session(
"s2",
"test",
vec![Message::text(
0,
MessageRole::User,
"Python web scraping tutorial",
)],
);
s.title = Some("Python web scraping".to_string());
s
};
service.load_sessions(vec![s1, s2]).await;
let results = service.search("Rust async").await;
#[cfg(feature = "search-index")]
{
assert!(!results.is_empty());
assert_eq!(results[0].id, "s1");
}
#[cfg(not(feature = "search-index"))]
{
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "s1");
}
}
#[tokio::test]
async fn test_search_by_message_content() {
use crate::model::{Message, MessageRole};
let service = SessionService::new();
let sessions = vec![make_test_session(
"s1",
"test",
vec![Message::text(
0,
MessageRole::User,
"How to use Rust async?",
)],
)];
service.load_sessions(sessions).await;
let results = service.search("rust async").await;
assert_eq!(results.len(), 1);
}
#[tokio::test]
async fn test_search_case_insensitive() {
let service = SessionService::new();
let sessions = vec![make_test_session("s1", "test", vec![])];
service.load_sessions(sessions).await;
let results = service.search("SESSION S1").await;
assert_eq!(results.len(), 1);
}
#[tokio::test]
async fn test_search_no_results() {
let service = SessionService::new();
let sessions = vec![make_test_session("s1", "test", vec![])];
service.load_sessions(sessions).await;
let results = service.search("xyzzy-zyzzyva-plugh").await;
#[cfg(feature = "search-index")]
{
if !results.is_empty() {
assert!(results.iter().all(|r| r.id == "s1"));
}
}
#[cfg(not(feature = "search-index"))]
{
assert!(results.is_empty());
}
}
#[tokio::test]
async fn test_sessions_by_source() {
let service = SessionService::new();
let sessions = vec![
make_test_session("s1", "claude", vec![]),
make_test_session("s2", "cursor", vec![]),
make_test_session("s3", "claude", vec![]),
];
service.load_sessions(sessions).await;
let claude_sessions = service.sessions_by_source("claude").await;
assert_eq!(claude_sessions.len(), 2);
let cursor_sessions = service.sessions_by_source("cursor").await;
assert_eq!(cursor_sessions.len(), 1);
}
#[tokio::test]
async fn test_clear_cache() {
let service = SessionService::new();
let sessions = vec![make_test_session("s1", "test", vec![])];
service.load_sessions(sessions).await;
assert_eq!(service.session_count().await, 1);
service.clear_cache().await;
assert_eq!(service.session_count().await, 0);
}
#[tokio::test]
async fn test_statistics_with_data() {
use crate::model::{Message, MessageRole};
let service = SessionService::new();
let sessions = vec![
make_test_session(
"s1",
"claude",
vec![
Message::text(0, MessageRole::User, "Hello"),
Message::text(1, MessageRole::Assistant, "Hi"),
],
),
make_test_session(
"s2",
"cursor",
vec![Message::text(0, MessageRole::User, "Help")],
),
];
service.load_sessions(sessions).await;
let stats = service.statistics().await;
assert_eq!(stats.total_sessions, 2);
assert_eq!(stats.total_messages, 3);
assert_eq!(stats.total_user_messages, 2);
assert_eq!(stats.total_assistant_messages, 1);
assert_eq!(stats.sessions_by_source.get("claude"), Some(&1));
assert_eq!(stats.sessions_by_source.get("cursor"), Some(&1));
}
#[tokio::test]
async fn test_load_sessions_deduplicates_by_id() {
let service = SessionService::new();
let sessions = vec![
make_test_session("s1", "test", vec![]),
make_test_session("s1", "test", vec![]), ];
service.load_sessions(sessions).await;
assert_eq!(service.session_count().await, 1);
}
#[tokio::test]
async fn test_extract_files_from_session() {
use crate::model::{ContentBlock, Message, MessageRole};
use serde_json::json;
let service = SessionService::new();
let mut msg = Message::text(0, MessageRole::Assistant, "reading files");
msg.blocks.push(ContentBlock::ToolUse {
id: "1".to_string(),
name: "Read".to_string(),
input: json!({"file_path": "/path/to/file.rs"}),
});
let session = Session {
id: "s1".to_string(),
source: "test".to_string(),
external_id: "s1".to_string(),
title: Some("Test Session".to_string()),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![msg],
metadata: crate::model::SessionMetadata::default(),
};
service.load_sessions(vec![session]).await;
let files = service.extract_files(&"s1".to_string()).await;
assert!(files.is_some());
let files = files.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "/path/to/file.rs");
}
#[tokio::test]
async fn test_extract_files_not_found() {
let service = SessionService::new();
let files = service.extract_files(&"nonexistent".to_string()).await;
assert!(files.is_none());
}
#[tokio::test]
async fn test_sessions_by_file() {
use crate::model::{ContentBlock, Message, MessageRole};
use serde_json::json;
let service = SessionService::new();
let mut msg1 = Message::text(0, MessageRole::Assistant, "reading files");
msg1.blocks.push(ContentBlock::ToolUse {
id: "1".to_string(),
name: "Read".to_string(),
input: json!({"file_path": "/src/main.rs"}),
});
let msg2 = Message::text(0, MessageRole::Assistant, "hello");
let sessions = vec![
Session {
id: "s1".to_string(),
source: "test".to_string(),
external_id: "s1".to_string(),
title: Some("Session 1".to_string()),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![msg1],
metadata: crate::model::SessionMetadata::default(),
},
Session {
id: "s2".to_string(),
source: "test".to_string(),
external_id: "s2".to_string(),
title: Some("Session 2".to_string()),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![msg2],
metadata: crate::model::SessionMetadata::default(),
},
];
service.load_sessions(sessions).await;
let matching = service.sessions_by_file("/src/main.rs").await;
assert_eq!(matching.len(), 1);
assert_eq!(matching[0].id, "s1");
let matching = service.sessions_by_file("main.rs").await;
assert_eq!(matching.len(), 1);
let matching = service.sessions_by_file("MAIN.RS").await;
assert_eq!(matching.len(), 1);
let matching = service.sessions_by_file("nonexistent").await;
assert!(matching.is_empty());
}
}
#[cfg(all(test, feature = "enrichment"))]
mod cluster_tests {
use super::*;
use crate::enrichment::{ConceptMatch, ConceptOccurrence, SessionConcepts};
use crate::model::SessionMetadata;
fn make_enriched_session(id: &str, concepts: &[&str]) -> Session {
let mut meta = SessionMetadata::default();
let mut sc = SessionConcepts::new(id.to_string());
for (idx, &term) in concepts.iter().enumerate() {
let mut cm = ConceptMatch::new(term.to_string(), term.to_string(), idx as u64, None);
cm.add_occurrence(ConceptOccurrence {
message_idx: 0,
start_pos: 0,
end_pos: term.len(),
context: None,
});
sc.insert_or_update(cm);
}
meta.enrichment = Some(sc);
Session {
id: id.to_string(),
source: "test".to_string(),
external_id: id.to_string(),
title: Some(format!("Session {}", id)),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![],
metadata: meta,
}
}
#[tokio::test]
async fn test_cluster_empty_service() {
let mut svc = SessionService::new();
svc.disable_auto_import();
let clusters = svc.cluster_by_concepts(None, None).await;
assert!(clusters.is_empty());
}
#[tokio::test]
async fn test_cluster_similar_sessions_grouped() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust", "async", "tokio"]);
let s2 = make_enriched_session("s2", &["rust", "async", "streams"]);
let s3 = make_enriched_session("s3", &["python", "django", "flask"]);
svc.load_sessions(vec![s1, s2, s3]).await;
let clusters = svc.cluster_by_concepts(None, None).await;
assert!(
clusters.len() >= 2,
"expected >= 2 clusters, got {}",
clusters.len()
);
let s1_cluster = clusters
.iter()
.find(|c| c.sessions.iter().any(|s| s.id == "s1"))
.expect("s1 not found in any cluster");
assert!(
s1_cluster.sessions.iter().any(|s| s.id == "s2"),
"s2 should be in the same cluster as s1"
);
}
#[tokio::test]
async fn test_cluster_with_k_cap() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust", "async"]);
let s2 = make_enriched_session("s2", &["python", "web"]);
let s3 = make_enriched_session("s3", &["java", "spring"]);
svc.load_sessions(vec![s1, s2, s3]).await;
let clusters = svc.cluster_by_concepts(Some(2), None).await;
assert!(
clusters.len() <= 2,
"expected <=2 clusters, got {}",
clusters.len()
);
}
#[tokio::test]
async fn test_cluster_unenriched_sessions_separate() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust"]);
let s2 = Session {
id: "s2".to_string(),
source: "test".to_string(),
external_id: "s2".to_string(),
title: Some("No concepts".to_string()),
source_path: std::path::PathBuf::from("."),
started_at: None,
ended_at: None,
messages: vec![],
metadata: SessionMetadata::default(),
};
svc.load_sessions(vec![s1, s2]).await;
let clusters = svc.cluster_by_concepts(None, None).await;
assert_eq!(
clusters.len(),
2,
"expected 2 clusters (enriched + unenriched)"
);
let unenriched = clusters.iter().find(|c| {
c.dominant_concepts
.iter()
.any(|d| d.contains("no enrichment"))
});
assert!(unenriched.is_some(), "expected an unenriched cluster");
}
#[tokio::test]
async fn test_cluster_dominant_concepts() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust", "async"]);
let s2 = make_enriched_session("s2", &["rust", "tokio"]);
svc.load_sessions(vec![s1, s2]).await;
let clusters = svc.cluster_by_concepts(None, None).await;
let rust_cluster = clusters
.iter()
.find(|c| c.sessions.iter().any(|s| s.id == "s1"))
.expect("cluster for s1 not found");
assert!(
rust_cluster.dominant_concepts.contains(&"rust".to_string()),
"rust should be dominant; got {:?}",
rust_cluster.dominant_concepts
);
}
#[tokio::test]
async fn test_cluster_min_sessions_filter() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust"]);
let s2 = make_enriched_session("s2", &["python"]);
svc.load_sessions(vec![s1, s2]).await;
let clusters = svc.cluster_by_concepts(None, Some(2)).await;
assert!(
clusters.is_empty(),
"expected 0 clusters with min_sessions=2, got {}",
clusters.len()
);
}
#[tokio::test]
async fn test_cluster_ids_sequential() {
let svc = SessionService::new();
let s1 = make_enriched_session("s1", &["rust"]);
let s2 = make_enriched_session("s2", &["python"]);
svc.load_sessions(vec![s1, s2]).await;
let clusters = svc.cluster_by_concepts(None, None).await;
for (i, c) in clusters.iter().enumerate() {
assert_eq!(
c.id,
i + 1,
"cluster IDs should be sequential starting from 1"
);
}
}
}