ryo-storage 0.1.0

Persistent storage and transaction log for RYO
Documentation
//! Session metadata index.
//!
//! Maintains a lightweight index of all stored sessions for fast queries
//! without loading full session files.

use crate::txlog::TxLog;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Metadata for a stored session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
    /// Unique session identifier.
    pub session_id: String,
    /// Project path this session operated on.
    pub project_path: PathBuf,
    /// When the session started (ISO 8601).
    pub started_at: String,
    /// When the session ended (ISO 8601), if applicable.
    pub ended_at: Option<String>,
    /// Number of entries in the session.
    pub entry_count: usize,
    /// Number of mutations applied.
    pub mutation_count: usize,
    /// Number of files modified.
    pub files_modified: usize,
    /// Total changes made.
    pub total_changes: usize,
    /// Optional session name/description.
    pub name: Option<String>,
    /// Tags for categorization.
    #[serde(default)]
    pub tags: Vec<String>,
}

impl SessionMeta {
    /// Create metadata from a TxLog.
    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(),
        }
    }

    /// Add a name to this session.
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }

    /// Add tags to this session.
    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
        self.tags = tags;
        self
    }

    /// Check if this session matches a project path.
    pub fn matches_project(&self, path: &Path) -> bool {
        self.project_path == path
    }
}

/// Index of all stored sessions.
#[derive(Debug, Clone, Default, Serialize)]
pub struct SessionIndex {
    /// All sessions, keyed by session ID.
    sessions: HashMap<String, SessionMeta>,
    /// Sessions grouped by project path (for fast lookup).
    #[serde(skip)]
    by_project: HashMap<PathBuf, Vec<String>>,
    /// Index version for future migrations.
    #[serde(default = "default_version")]
    version: u32,
}

fn default_version() -> u32 {
    1
}

impl SessionIndex {
    /// Create a new empty index.
    pub fn new() -> Self {
        Self {
            sessions: HashMap::new(),
            by_project: HashMap::new(),
            version: 1,
        }
    }

    /// Add a session to the index.
    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);
    }

    /// Remove a session from the index.
    pub fn remove(&mut self, session_id: &str) -> Option<SessionMeta> {
        if let Some(meta) = self.sessions.remove(session_id) {
            // Remove from project index
            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
        }
    }

    /// Get a session by ID.
    pub fn get(&self, session_id: &str) -> Option<&SessionMeta> {
        self.sessions.get(session_id)
    }

    /// List all sessions, sorted by start time (newest first).
    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
    }

    /// Get sessions for a specific project.
    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
    }

    /// Get the most recent session.
    pub fn latest(&self) -> Option<&SessionMeta> {
        self.list().into_iter().next()
    }

    /// Get the most recent session for a project.
    pub fn latest_for_project(&self, project_path: &Path) -> Option<&SessionMeta> {
        self.by_project(project_path).into_iter().next()
    }

    /// Get all unique project paths.
    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
    }

    /// Count total sessions.
    pub fn count(&self) -> usize {
        self.sessions.len()
    }

    /// Count sessions for a project.
    pub fn count_for_project(&self, project_path: &Path) -> usize {
        self.sessions
            .values()
            .filter(|m| m.matches_project(project_path))
            .count()
    }

    /// Cleanup old sessions, keeping only the N most recent per project.
    ///
    /// Returns the list of session IDs that were removed.
    pub fn cleanup(&mut self, keep_per_project: usize) -> Vec<String> {
        let mut to_remove = Vec::new();

        // Group by project and find sessions to remove
        let projects: Vec<_> = self.projects().into_iter().cloned().collect();

        for project in projects {
            let mut sessions = self.by_project(&project);
            // Already sorted newest first
            if sessions.len() > keep_per_project {
                for meta in sessions.drain(keep_per_project..) {
                    to_remove.push(meta.session_id.clone());
                }
            }
        }

        // Remove from index
        for session_id in &to_remove {
            self.remove(session_id);
        }

        to_remove
    }

    /// Rebuild the by_project index (call after deserialization).
    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());
        }
    }

    /// Search sessions by tags.
    pub fn by_tags(&self, tags: &[String]) -> Vec<&SessionMeta> {
        self.sessions
            .values()
            .filter(|m| tags.iter().any(|t| m.tags.contains(t)))
            .collect()
    }

    /// Search sessions by name pattern.
    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()
    }
}

// Custom deserialize to rebuild project index
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"); // Newest first
    }

    #[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);

        // Should keep 2 per project, remove oldest
        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());

        // Project index should be rebuilt
        assert_eq!(restored.by_project(Path::new("/project/a")).len(), 1);
    }
}