mps-rs 1.8.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
use crate::llm::Message;
use anyhow::{bail, Context as _};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSession {
    pub version: u32,
    pub name: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub context_config: SessionContextConfig,
    /// Informational record of which LLM was active when last saved. Never used to select the LLM.
    pub llm_hint: String,
    pub messages: Vec<SessionMessage>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
    pub role: String,
    pub content: String,
    pub ts: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionContextConfig {
    pub context_days: u64,
    pub since: Option<String>,
}

#[derive(Debug, Clone)]
pub struct SessionSummary {
    pub name: String,
    pub updated_at: DateTime<Utc>,
    pub message_count: usize,
}

/// Validate a session name: alphanumeric, hyphens, underscores, dots only.
/// Rejects empty names, names with path separators, and `..`.
pub fn validate_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() {
        bail!("session name cannot be empty");
    }
    if name == ".." || name == "." {
        bail!("'{}' is not a valid session name", name);
    }
    if name.contains('/') || name.contains('\\') {
        bail!("session name cannot contain path separators: '{}'", name);
    }
    if !name
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
    {
        bail!(
            "session name '{}' contains invalid characters (use letters, digits, -, _, .)",
            name
        );
    }
    Ok(())
}

impl ChatSession {
    pub fn new(name: &str, context_config: SessionContextConfig) -> Self {
        let now = Utc::now();
        Self {
            version: 1,
            name: name.to_string(),
            created_at: now,
            updated_at: now,
            context_config,
            llm_hint: String::new(),
            messages: Vec::new(),
        }
    }

    pub fn load(sessions_dir: &Path, name: &str) -> anyhow::Result<Self> {
        validate_name(name)?;
        let path = sessions_dir.join(format!("{}.json", name));
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("cannot read session file {}", path.display()))?;
        let session: ChatSession = serde_json::from_str(&content)
            .with_context(|| format!("corrupt session file {}", path.display()))?;
        Ok(session)
    }

    /// Atomic save (tmp + rename). Creates sessions_dir if it doesn't exist.
    pub fn save(&mut self, sessions_dir: &Path) -> anyhow::Result<()> {
        validate_name(&self.name)?;
        std::fs::create_dir_all(sessions_dir).context("cannot create sessions directory")?;
        self.updated_at = Utc::now();
        let path = sessions_dir.join(format!("{}.json", self.name));
        let tmp = sessions_dir.join(format!("{}.json.tmp.{}", self.name, std::process::id()));
        let json = serde_json::to_string_pretty(self).context("serialize session")?;
        std::fs::write(&tmp, &json).context("write session tmp")?;
        std::fs::rename(&tmp, &path).context("rename session tmp")?;
        Ok(())
    }

    /// List all sessions in sessions_dir, sorted by updated_at descending.
    pub fn list(sessions_dir: &Path) -> anyhow::Result<Vec<SessionSummary>> {
        if !sessions_dir.exists() {
            return Ok(Vec::new());
        }
        let mut summaries = Vec::new();
        for entry in std::fs::read_dir(sessions_dir).context("read sessions dir")? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            if let Ok(content) = std::fs::read_to_string(&path) {
                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
                    summaries.push(SessionSummary {
                        name: session.name.clone(),
                        updated_at: session.updated_at,
                        message_count: session.messages.len(),
                    });
                }
            }
        }
        summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
        Ok(summaries)
    }

    /// Convert stored messages to LLM Message format (strips timestamps).
    pub fn to_messages(&self) -> Vec<Message> {
        self.messages
            .iter()
            .map(|m| Message {
                role: m.role.clone(),
                content: m.content.clone(),
            })
            .collect()
    }

    pub fn push_user(&mut self, content: String) {
        self.messages.push(SessionMessage {
            role: "user".into(),
            content,
            ts: Utc::now(),
        });
    }

    pub fn push_assistant(&mut self, content: String) {
        self.messages.push(SessionMessage {
            role: "assistant".into(),
            content,
            ts: Utc::now(),
        });
    }
}