ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use crate::config::Config;
use crate::errors::{RalphError, Result};
use crate::providers::Message;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
    InProgress,
    Done,
    Failed,
    Interrupted,
}

impl std::fmt::Display for SessionStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SessionStatus::InProgress => write!(f, "in_progress"),
            SessionStatus::Done => write!(f, "done"),
            SessionStatus::Failed => write!(f, "failed"),
            SessionStatus::Interrupted => write!(f, "interrupted"),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
    pub session_id: String,
    pub workspace: String,
    pub provider: String,
    pub model: String,
    pub created_at: DateTime<Utc>,
    pub last_active: DateTime<Utc>,
    pub turn_count: u32,
    pub status: SessionStatus,
}

#[derive(Debug)]
pub struct Session {
    pub meta: SessionMeta,
    pub messages: Vec<Message>,
    pub session_dir: PathBuf,
    /// Files Ralph has modified in this session (for checkpoint snapshotting).
    pub modified_files: Vec<PathBuf>,
}

impl Session {
    /// Create a fresh session.
    pub fn create(workspace: &Path, provider: &str, model: &str, config: &Config) -> Result<Self> {
        let now = Utc::now();
        let date_str = now.format("%Y-%m-%d").to_string();
        let short_id = Uuid::new_v4().to_string()[..6].to_string();
        let session_id = format!("{}_{}", date_str, short_id);

        let session_dir = config.sessions_dir().join(&session_id);
        std::fs::create_dir_all(&session_dir)?;
        std::fs::create_dir_all(session_dir.join("checkpoints"))?;

        let meta = SessionMeta {
            session_id: session_id.clone(),
            workspace: workspace.display().to_string(),
            provider: provider.to_string(),
            model: model.to_string(),
            created_at: now,
            last_active: now,
            turn_count: 0,
            status: SessionStatus::InProgress,
        };

        let session = Self {
            meta,
            messages: Vec::new(),
            session_dir,
            modified_files: Vec::new(),
        };
        session.save_meta()?;
        Ok(session)
    }

    /// Load an existing session by ID.
    pub fn load(session_id: &str, config: &Config) -> Result<Self> {
        let session_dir = config.sessions_dir().join(session_id);
        if !session_dir.exists() {
            return Err(RalphError::SessionNotFound(session_id.to_string()));
        }

        let meta = load_json::<SessionMeta>(&session_dir.join("session.json"))
            .map_err(|e| RalphError::SessionCorrupt(e.to_string()))?;

        let messages =
            load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();

        Ok(Self {
            meta,
            messages,
            session_dir,
            modified_files: Vec::new(),
        })
    }

    /// Find the most recent session for the given workspace.
    pub fn find_for_workspace(workspace: &Path, config: &Config) -> Option<Self> {
        let sessions_dir = config.sessions_dir();
        if !sessions_dir.exists() {
            return None;
        }

        let workspace_str = workspace.display().to_string();

        let mut candidates: Vec<(SessionMeta, PathBuf)> = std::fs::read_dir(&sessions_dir)
            .ok()?
            .flatten()
            .filter(|e| e.path().is_dir())
            .filter_map(|e| {
                let meta_path = e.path().join("session.json");
                let meta: SessionMeta = load_json(&meta_path).ok()?;
                if meta.workspace == workspace_str
                    && (meta.status == SessionStatus::Interrupted
                        || meta.status == SessionStatus::InProgress)
                {
                    Some((meta, e.path()))
                } else {
                    None
                }
            })
            .collect();

        candidates.sort_by(|a, b| b.0.last_active.cmp(&a.0.last_active));

        let (meta, session_dir) = candidates.into_iter().next()?;
        let messages =
            load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();

        Some(Self {
            meta,
            messages,
            session_dir,
            modified_files: Vec::new(),
        })
    }

    /// List all sessions, sorted by last_active descending.
    pub fn list_all(config: &Config) -> Vec<SessionMeta> {
        let sessions_dir = config.sessions_dir();
        if !sessions_dir.exists() {
            return Vec::new();
        }

        let mut sessions: Vec<SessionMeta> = std::fs::read_dir(&sessions_dir)
            .unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
            .flatten()
            .filter(|e| e.path().is_dir())
            .filter_map(|e| load_json::<SessionMeta>(&e.path().join("session.json")).ok())
            .collect();

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

    /// Append messages and persist.
    pub fn save_turn(&mut self, new_messages: &[Message], turn_count: u32) -> Result<()> {
        for msg in new_messages {
            self.messages.push(msg.clone());
        }
        self.meta.turn_count = turn_count;
        self.meta.last_active = Utc::now();
        self.flush()
    }

    pub fn set_status(&mut self, status: SessionStatus) -> Result<()> {
        self.meta.status = status;
        self.meta.last_active = Utc::now();
        self.save_meta()
    }

    pub fn flush(&self) -> Result<()> {
        self.save_meta()?;
        save_json(&self.session_dir.join("messages.json"), &self.messages)
    }

    fn save_meta(&self) -> Result<()> {
        save_json(&self.session_dir.join("session.json"), &self.meta)
    }

    pub fn checkpoints_dir(&self) -> PathBuf {
        self.session_dir.join("checkpoints")
    }

    /// Append a line to the human-readable session log in ~/.ralph/logs/.
    pub fn log_event(&self, event: &str) {
        if let Some(log_path) = self.log_path() {
            if let Some(parent) = log_path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let line = format!("[{}] {}\n", Utc::now().format("%Y-%m-%dT%H:%M:%SZ"), event);
            if let Ok(mut f) = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(&log_path)
            {
                let _ = f.write_all(line.as_bytes());
            }
        }
    }

    fn log_path(&self) -> Option<PathBuf> {
        let date = Utc::now().format("%Y-%m-%d").to_string();
        let home = dirs::home_dir()?;
        Some(
            home.join(".ralph")
                .join("logs")
                .join(format!("{}_{}.log", date, self.meta.session_id)),
        )
    }

    /// Delete sessions older than `days`.
    pub fn clean_old(config: &Config, days: u32) {
        let sessions_dir = config.sessions_dir();
        if !sessions_dir.exists() {
            return;
        }
        let cutoff = Utc::now() - chrono::Duration::days(days as i64);

        if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
            for entry in entries.flatten() {
                if !entry.path().is_dir() {
                    continue;
                }
                let meta_path = entry.path().join("session.json");
                if let Ok(meta) = load_json::<SessionMeta>(&meta_path) {
                    if meta.last_active < cutoff {
                        let _ = std::fs::remove_dir_all(entry.path());
                    }
                }
            }
        }
    }
}

fn load_json<T: for<'de> Deserialize<'de>>(path: &Path) -> anyhow::Result<T> {
    let content = std::fs::read_to_string(path)?;
    Ok(serde_json::from_str(&content)?)
}

fn save_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    let content = serde_json::to_string_pretty(value)?;
    std::fs::write(path, content)?;
    Ok(())
}