gigi-cli 1.0.1

Gigi — A Claude Code-like AI coding assistant CLI in Rust
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::query::types::Message;

// =============================================================================
// Session — Persistent conversation state
//
// Sessions are saved to .sessions/<session-id>/session.json
// They store the full conversation history so you can close the tool
// and resume later without losing context.
// =============================================================================

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    /// Unique session identifier.
    pub id: String,

    /// When this session was first created.
    pub created_at: DateTime<Utc>,

    /// When this session was last modified.
    pub updated_at: DateTime<Utc>,

    /// Full conversation history (user + assistant + tool_result messages).
    pub messages: Vec<Message>,

    /// The working directory when the session was created.
    pub working_dir: String,

    /// Which model provider was being used.
    pub provider_name: String,

    /// Which model was being used.
    pub model_name: String,

    /// A short summary/title for the session (first user message, truncated).
    pub title: String,
}

impl Session {
    /// Create a new empty session.
    pub fn new(provider_name: &str, model_name: &str) -> Self {
        let working_dir = std::env::current_dir()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| ".".to_string());

        Self {
            id: uuid::Uuid::new_v4().to_string(),
            created_at: Utc::now(),
            updated_at: Utc::now(),
            messages: Vec::new(),
            working_dir,
            provider_name: provider_name.to_string(),
            model_name: model_name.to_string(),
            title: String::new(),
        }
    }

    /// Add a message to the session and update the title if needed.
    pub fn add_message(&mut self, message: Message) {
        // Set title from the first user message
        if self.title.is_empty() {
            if let Some(text) = message.text() {
                self.title = text.chars().take(80).collect();
                if text.len() > 80 {
                    self.title.push_str("...");
                }
            }
        }

        self.messages.push(message);
        self.updated_at = Utc::now();
    }

    /// Get the session directory path.
    fn session_dir(base_dir: &Path, session_id: &str) -> PathBuf {
        base_dir.join(session_id)
    }

    /// Get the session file path.
    fn session_file(base_dir: &Path, session_id: &str) -> PathBuf {
        Self::session_dir(base_dir, session_id).join("session.json")
    }

    /// Save this session to disk (atomic: write temp file then rename).
    pub async fn save(&self, base_dir: &Path) -> Result<()> {
        let dir = Self::session_dir(base_dir, &self.id);
        tokio::fs::create_dir_all(&dir)
            .await
            .context("Failed to create session directory")?;

        let file_path = Self::session_file(base_dir, &self.id);
        let temp_path = dir.join("session.json.tmp");

        let json = serde_json::to_string_pretty(self)
            .context("Failed to serialize session")?;

        tokio::fs::write(&temp_path, &json)
            .await
            .context("Failed to write session temp file")?;

        tokio::fs::rename(&temp_path, &file_path)
            .await
            .context("Failed to rename session file")?;

        Ok(())
    }

    /// Load a session from disk by ID.
    pub async fn load(base_dir: &Path, session_id: &str) -> Result<Self> {
        let file_path = Self::session_file(base_dir, session_id);

        let json = tokio::fs::read_to_string(&file_path)
            .await
            .with_context(|| format!("Failed to read session file: {}", file_path.display()))?;

        let session: Self = serde_json::from_str(&json)
            .context("Failed to deserialize session")?;

        Ok(session)
    }

    /// List all saved sessions, sorted by last modified time (newest first).
    pub async fn list_all(base_dir: &Path) -> Result<Vec<SessionSummary>> {
        let mut summaries = Vec::new();

        if !base_dir.exists() {
            return Ok(summaries);
        }

        let mut entries = tokio::fs::read_dir(base_dir)
            .await
            .context("Failed to read session directory")?;

        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            if path.is_dir() {
                let session_file = path.join("session.json");
                if session_file.exists() {
                    match tokio::fs::read_to_string(&session_file).await {
                        Ok(json) => {
                            if let Ok(session) = serde_json::from_str::<Session>(&json) {
                                summaries.push(SessionSummary {
                                    id: session.id,
                                    title: session.title,
                                    created_at: session.created_at,
                                    updated_at: session.updated_at,
                                    message_count: session.messages.len(),
                                    provider: session.provider_name,
                                    model: session.model_name,
                                });
                            }
                        }
                        Err(_) => continue,
                    }
                }
            }
        }

        // Sort newest first
        summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));

        Ok(summaries)
    }
}

/// A lightweight summary of a session (for listing).
#[derive(Debug)]
pub struct SessionSummary {
    pub id: String,
    pub title: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub message_count: usize,
    pub provider: String,
    pub model: String,
}

impl std::fmt::Display for SessionSummary {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "[{}] {} ({} messages, {} / {}, updated {})",
            &self.id[..8],
            if self.title.is_empty() {
                "(untitled)"
            } else {
                &self.title
            },
            self.message_count,
            self.provider,
            self.model,
            self.updated_at.format("%Y-%m-%d %H:%M")
        )
    }
}