collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use crate::api::models::Message;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Serializable snapshot of a conversation session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSnapshot {
    /// Unique session identifier.
    pub session_id: String,
    /// Working directory at time of snapshot.
    pub working_dir: String,
    /// The system prompt in use.
    pub system_prompt: String,
    /// All conversation messages.
    pub messages: Vec<Message>,
    /// Last reasoning content (preserved thinking).
    #[serde(default)]
    pub last_reasoning: Option<String>,
    /// Timestamp of the snapshot (RFC 3339).
    pub timestamp: String,
    /// Whether the session completed normally.
    pub completed: bool,
    /// The original user task (first user message).
    #[serde(default)]
    pub user_task: Option<String>,
    /// Model used for this session.
    #[serde(default)]
    pub model: Option<String>,

    // ── UI state (restored on resume) ──
    /// Serialized TUI state for full session restore.
    /// Contains: messages, tool_log, changed_files, iteration, elapsed_secs,
    /// token_stats, compaction_count, cache_hit_rate, agent_mode.
    #[serde(default)]
    pub ui_state: Option<serde_json::Value>,
}

/// Manages session persistence to disk.
pub struct SessionStore {
    session_dir: PathBuf,
}

impl SessionStore {
    /// Create a new session store.
    ///
    /// Sessions are saved under `~/.local/share/collet/projects/<hash>/sessions/`
    /// where `<hash>` is a BLAKE3 prefix of the canonical working directory.
    pub fn new(working_dir: &str) -> Self {
        let session_dir = crate::config::project_data_dir(working_dir).join("sessions");
        if let Err(e) = std::fs::create_dir_all(&session_dir) {
            tracing::warn!("Failed to create session directory {:?}: {e}", session_dir);
        }
        Self { session_dir }
    }

    /// Save a session snapshot to disk.
    ///
    /// Uses write-to-temp + rename for atomicity so a crash mid-write
    /// never leaves a truncated file.
    pub async fn save(&self, snapshot: &SessionSnapshot) -> Result<()> {
        let filename = format!("{}.json", snapshot.session_id);
        let path = self.session_dir.join(&filename);

        let json = serde_json::to_string_pretty(snapshot)?;
        let tmp_path = path.with_extension("json.tmp");
        tokio::fs::write(&tmp_path, &json).await?;
        tokio::fs::rename(&tmp_path, &path).await?;

        // Also update the "latest" pointer (atomic rename).
        let latest_path = self.session_dir.join("latest.json");
        let latest_tmp = latest_path.with_extension("json.tmp");
        let latest_content = serde_json::json!({
            "session_id": snapshot.session_id,
            "timestamp": snapshot.timestamp,
            "completed": snapshot.completed,
        });
        tokio::fs::write(&latest_tmp, latest_content.to_string()).await?;
        tokio::fs::rename(&latest_tmp, &latest_path).await?;

        tracing::debug!(
            session_id = %snapshot.session_id,
            completed = snapshot.completed,
            messages = snapshot.messages.len(),
            "Session saved",
        );

        Ok(())
    }

    /// Load a specific session by ID.
    pub async fn load(&self, session_id: &str) -> Result<SessionSnapshot> {
        let filename = format!("{session_id}.json");
        let path = self.session_dir.join(&filename);
        let content = tokio::fs::read_to_string(&path).await?;
        let snapshot: SessionSnapshot = serde_json::from_str(&content)?;
        Ok(snapshot)
    }

    /// Find the most recent incomplete session (for auto-resume).
    pub async fn find_incomplete(&self) -> Option<SessionSnapshot> {
        let latest_path = self.session_dir.join("latest.json");
        let content = tokio::fs::read_to_string(&latest_path).await.ok()?;
        let meta: serde_json::Value = serde_json::from_str(&content).ok()?;

        let completed = meta.get("completed")?.as_bool()?;
        if completed {
            return None;
        }

        let session_id = meta.get("session_id")?.as_str()?;
        self.load(session_id).await.ok()
    }

    /// List all saved sessions, newest first.
    pub async fn list(&self) -> Vec<(String, String, bool)> {
        let mut sessions = Vec::new();

        let mut entries = match tokio::fs::read_dir(&self.session_dir).await {
            Ok(e) => e,
            Err(_) => return sessions,
        };

        while let Ok(Some(entry)) = entries.next_entry().await {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
                continue;
            }

            if let Ok(content) = tokio::fs::read_to_string(&path).await
                && let Ok(snap) = serde_json::from_str::<SessionSnapshot>(&content)
            {
                sessions.push((snap.session_id, snap.timestamp, snap.completed));
            }
        }

        sessions.sort_by(|a, b| b.1.cmp(&a.1));
        sessions
    }

    /// Delete a session by ID. Returns true if deleted.
    pub async fn delete(&self, session_id: &str) -> bool {
        let filename = format!("{session_id}.json");
        let path = self.session_dir.join(&filename);
        match tokio::fs::remove_file(&path).await {
            Ok(_) => {
                tracing::info!(session_id, "Session deleted");
                // Also clear latest.json if it matches
                let latest_path = self.session_dir.join("latest.json");
                if let Ok(content) = tokio::fs::read_to_string(&latest_path).await
                    && let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content)
                    && meta.get("session_id").and_then(|v| v.as_str()) == Some(session_id)
                {
                    let _ = tokio::fs::remove_file(&latest_path).await;
                }
                true
            }
            Err(_) => false,
        }
    }

    /// Clean up old completed sessions, keeping the last N (called after each save).
    pub async fn cleanup(&self, keep: usize) -> Result<usize> {
        let sessions = self.list().await;
        let completed: Vec<_> = sessions.iter().filter(|(_, _, c)| *c).collect();

        let mut removed = 0;
        if completed.len() > keep {
            for (id, _, _) in &completed[keep..] {
                let path = self.session_dir.join(format!("{id}.json"));
                if tokio::fs::remove_file(&path).await.is_ok() {
                    removed += 1;
                }
            }
        }

        Ok(removed)
    }
}

/// Detect if there's an incomplete session from `STATUS.md` in project data dir.
///
/// Called by `try_resume_session` as a secondary fallback alongside `find_incomplete`.
pub async fn detect_incomplete_journal(working_dir: &str) -> Option<String> {
    let status_path = crate::config::project_data_dir(working_dir).join("STATUS.md");
    let content = tokio::fs::read_to_string(&status_path).await.ok()?;

    // Check if the journal shows a non-completed state
    if content.contains("| Phase | `completed` |") {
        return None;
    }

    // Extract session ID
    for line in content.lines() {
        if line.contains("| Session |") {
            let id = line.split('`').nth(1)?;
            return Some(id.to_string());
        }
    }

    None
}