oy-cli 0.7.16

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

use super::mode::SafetyMode;
use super::paths::{sessions_dir, write_private_file};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionFile {
    pub model: String,
    pub saved_at: String,
    #[serde(default)]
    pub workspace_root: Option<PathBuf>,
    #[serde(default)]
    pub mode: Option<SafetyMode>,
    pub transcript: serde_json::Value,
    #[serde(default)]
    pub todos: Vec<crate::tools::TodoItem>,
}

pub fn save_session_file(name: Option<&str>, file: &SessionFile) -> Result<PathBuf> {
    let sessions = sessions_dir()?;
    let stem = name
        .filter(|s| !s.trim().is_empty())
        .map(sanitize_session_name)
        .unwrap_or_else(|| Utc::now().format("%Y%m%d-%H%M%S").to_string());
    let path = sessions.join(format!("{stem}.json"));
    let body = serde_json::to_string_pretty(file)?;
    write_private_file(&path, body.as_bytes())?;
    Ok(path)
}

pub fn list_saved_sessions() -> Result<Vec<PathBuf>> {
    let dir = sessions_dir()?;
    let mut items = fs::read_dir(&dir)?
        .filter_map(|entry| entry.ok().map(|e| e.path()))
        .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("json"))
        .collect::<Vec<_>>();
    items.sort_by_key(|path| {
        fs::metadata(path)
            .and_then(|m| m.modified())
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
    });
    items.reverse();
    Ok(items)
}

pub fn resolve_saved_session(name: Option<&str>) -> Result<Option<PathBuf>> {
    let sessions = list_saved_sessions()?;
    if sessions.is_empty() {
        return Ok(None);
    }
    let Some(name) = name else {
        return Ok(sessions.first().cloned());
    };
    if let Ok(index) = name.parse::<usize>()
        && index >= 1
        && index <= sessions.len()
    {
        return Ok(Some(sessions[index - 1].clone()));
    }
    if let Some(exact) = sessions
        .iter()
        .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(name))
    {
        return Ok(Some(exact.clone()));
    }
    Ok(sessions
        .iter()
        .find(|p| {
            p.file_stem()
                .and_then(|s| s.to_str())
                .is_some_and(|s| s.contains(name))
        })
        .cloned())
}

pub fn load_session_file(path: &Path) -> Result<SessionFile> {
    let data =
        fs::read_to_string(path).with_context(|| format!("failed reading {}", path.display()))?;
    serde_json::from_str(&data).with_context(|| format!("failed parsing {}", path.display()))
}

pub fn sanitize_session_name(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
            out.push(ch);
        } else if ch.is_whitespace() {
            out.push('-');
        }
    }
    let trimmed = out.trim_matches('-');
    if trimmed.is_empty() {
        "session".to_string()
    } else {
        trimmed.to_string()
    }
}