use crate::txlog::TxLog;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub session_id: String,
pub project_path: PathBuf,
pub started_at: String,
pub ended_at: Option<String>,
pub entry_count: usize,
pub mutation_count: usize,
pub files_modified: usize,
pub total_changes: usize,
pub name: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
impl SessionMeta {
pub fn from_log(log: &TxLog) -> Self {
let summary = log.summary();
Self {
session_id: log.session_id.clone(),
project_path: PathBuf::from(&log.project_path),
started_at: log.started_at.clone(),
ended_at: log.ended_at.clone(),
entry_count: log.entries().len(),
mutation_count: summary.total_mutations,
files_modified: summary.files_modified,
total_changes: summary.total_changes,
name: None,
tags: Vec::new(),
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn matches_project(&self, path: &Path) -> bool {
self.project_path == path
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SessionIndex {
sessions: HashMap<String, SessionMeta>,
#[serde(skip)]
by_project: HashMap<PathBuf, Vec<String>>,
#[serde(default = "default_version")]
version: u32,
}
fn default_version() -> u32 {
1
}
impl SessionIndex {
pub fn new() -> Self {
Self {
sessions: HashMap::new(),
by_project: HashMap::new(),
version: 1,
}
}
pub fn add(&mut self, meta: SessionMeta) {
let session_id = meta.session_id.clone();
let project_path = meta.project_path.clone();
self.sessions.insert(session_id.clone(), meta);
self.by_project
.entry(project_path)
.or_default()
.push(session_id);
}
pub fn remove(&mut self, session_id: &str) -> Option<SessionMeta> {
if let Some(meta) = self.sessions.remove(session_id) {
if let Some(project_sessions) = self.by_project.get_mut(&meta.project_path) {
project_sessions.retain(|id| id != session_id);
if project_sessions.is_empty() {
self.by_project.remove(&meta.project_path);
}
}
Some(meta)
} else {
None
}
}
pub fn get(&self, session_id: &str) -> Option<&SessionMeta> {
self.sessions.get(session_id)
}
pub fn list(&self) -> Vec<&SessionMeta> {
let mut sessions: Vec<_> = self.sessions.values().collect();
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
pub fn by_project(&self, project_path: &Path) -> Vec<&SessionMeta> {
let mut sessions: Vec<_> = self
.sessions
.values()
.filter(|m| m.matches_project(project_path))
.collect();
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
pub fn latest(&self) -> Option<&SessionMeta> {
self.list().into_iter().next()
}
pub fn latest_for_project(&self, project_path: &Path) -> Option<&SessionMeta> {
self.by_project(project_path).into_iter().next()
}
pub fn projects(&self) -> Vec<&PathBuf> {
let mut paths: Vec<_> = self
.sessions
.values()
.map(|m| &m.project_path)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
paths.sort();
paths
}
pub fn count(&self) -> usize {
self.sessions.len()
}
pub fn count_for_project(&self, project_path: &Path) -> usize {
self.sessions
.values()
.filter(|m| m.matches_project(project_path))
.count()
}
pub fn cleanup(&mut self, keep_per_project: usize) -> Vec<String> {
let mut to_remove = Vec::new();
let projects: Vec<_> = self.projects().into_iter().cloned().collect();
for project in projects {
let mut sessions = self.by_project(&project);
if sessions.len() > keep_per_project {
for meta in sessions.drain(keep_per_project..) {
to_remove.push(meta.session_id.clone());
}
}
}
for session_id in &to_remove {
self.remove(session_id);
}
to_remove
}
pub fn rebuild_project_index(&mut self) {
self.by_project.clear();
for (session_id, meta) in &self.sessions {
self.by_project
.entry(meta.project_path.clone())
.or_default()
.push(session_id.clone());
}
}
pub fn by_tags(&self, tags: &[String]) -> Vec<&SessionMeta> {
self.sessions
.values()
.filter(|m| tags.iter().any(|t| m.tags.contains(t)))
.collect()
}
pub fn by_name_contains(&self, pattern: &str) -> Vec<&SessionMeta> {
let pattern_lower = pattern.to_lowercase();
self.sessions
.values()
.filter(|m| {
m.name
.as_ref()
.map(|n| n.to_lowercase().contains(&pattern_lower))
.unwrap_or(false)
})
.collect()
}
}
impl<'de> serde::de::Deserialize<'de> for SessionIndex {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
#[derive(Deserialize)]
struct IndexData {
sessions: HashMap<String, SessionMeta>,
#[serde(default = "default_version")]
version: u32,
}
let data = IndexData::deserialize(deserializer)?;
let mut index = SessionIndex {
sessions: data.sessions,
by_project: HashMap::new(),
version: data.version,
};
index.rebuild_project_index();
Ok(index)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_meta(id: &str, project: &str, time: &str) -> SessionMeta {
SessionMeta {
session_id: id.to_string(),
project_path: PathBuf::from(project),
started_at: time.to_string(),
ended_at: None,
entry_count: 10,
mutation_count: 5,
files_modified: 3,
total_changes: 15,
name: None,
tags: Vec::new(),
}
}
#[test]
fn test_add_and_get() {
let mut index = SessionIndex::new();
let meta = create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z");
index.add(meta);
assert!(index.get("s1").is_some());
assert!(index.get("s2").is_none());
}
#[test]
fn test_list_sorted() {
let mut index = SessionIndex::new();
index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
index.add(create_test_meta("s2", "/project/a", "2024-01-02T10:00:00Z"));
index.add(create_test_meta("s3", "/project/a", "2024-01-01T15:00:00Z"));
let list = index.list();
assert_eq!(list[0].session_id, "s2");
assert_eq!(list[1].session_id, "s3");
assert_eq!(list[2].session_id, "s1");
}
#[test]
fn test_by_project() {
let mut index = SessionIndex::new();
index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
index.add(create_test_meta("s2", "/project/b", "2024-01-02T10:00:00Z"));
index.add(create_test_meta("s3", "/project/a", "2024-01-03T10:00:00Z"));
let proj_a = index.by_project(Path::new("/project/a"));
assert_eq!(proj_a.len(), 2);
assert_eq!(proj_a[0].session_id, "s3"); }
#[test]
fn test_cleanup() {
let mut index = SessionIndex::new();
index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
index.add(create_test_meta("s2", "/project/a", "2024-01-02T10:00:00Z"));
index.add(create_test_meta("s3", "/project/a", "2024-01-03T10:00:00Z"));
index.add(create_test_meta("s4", "/project/b", "2024-01-01T10:00:00Z"));
index.add(create_test_meta("s5", "/project/b", "2024-01-02T10:00:00Z"));
let removed = index.cleanup(2);
assert_eq!(removed.len(), 1);
assert!(removed.contains(&"s1".to_string()));
assert_eq!(index.count(), 4);
}
#[test]
fn test_remove() {
let mut index = SessionIndex::new();
index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
let removed = index.remove("s1");
assert!(removed.is_some());
assert!(index.get("s1").is_none());
}
#[test]
fn test_serialization_roundtrip() {
let mut index = SessionIndex::new();
index.add(create_test_meta("s1", "/project/a", "2024-01-01T10:00:00Z"));
index.add(create_test_meta("s2", "/project/b", "2024-01-02T10:00:00Z"));
let json = serde_json::to_string(&index).unwrap();
let restored: SessionIndex = serde_json::from_str(&json).unwrap();
assert_eq!(restored.count(), 2);
assert!(restored.get("s1").is_some());
assert!(restored.get("s2").is_some());
assert_eq!(restored.by_project(Path::new("/project/a")).len(), 1);
}
}