Vectrum
Sign In
100 lines · 3.1 KB · Rust
//! Threads and projects kept between runs, under
//! `$XDG_DATA_HOME/vectrum-code`: one JSON file per thread in `threads/`,
//! and `state.json` for the project list and the open thread.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::session::SavedThread;
use crate::settings;

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SavedProject {
    pub path: PathBuf,
    pub name: String,
    /// Milliseconds since the epoch.
    pub added: u64,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppState {
    pub projects: Vec<SavedProject>,
    /// The thread that was open.
    pub active: Option<String>,
}

fn threads_dir() -> Option<PathBuf> {
    Some(settings::data_dir()?.join("threads"))
}

fn state_path() -> Option<PathBuf> {
    Some(settings::data_dir()?.join("state.json"))
}

/// Writes through a temporary file, so a crash mid-write can't leave a
/// half-written file behind.
fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let temp = path.with_extension("json.tmp");
    std::fs::write(&temp, contents)?;
    std::fs::rename(temp, path)
}

pub fn load_state() -> AppState {
    state_path()
        .and_then(|path| std::fs::read_to_string(path).ok())
        .and_then(|text| serde_json::from_str(&text).ok())
        .unwrap_or_default()
}

pub fn save_state(state: &AppState) {
    let Some(path) = state_path() else { return };
    if let Err(err) = write_atomically(&path, serde_json::to_string_pretty(state).unwrap_or_default().as_bytes()) {
        eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
    }
}

/// Every saved thread that still reads.
pub fn load_threads() -> Vec<SavedThread> {
    let Some(dir) = threads_dir() else { return Vec::new() };
    let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
    entries
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.extension().is_some_and(|extension| extension == "json"))
        .filter_map(|path| {
            let text = std::fs::read_to_string(&path).ok()?;
            match serde_json::from_str(&text) {
                Ok(thread) => Some(thread),
                Err(err) => {
                    eprintln!("vectrum-code: skipping {}: {err}", path.display());
                    None
                }
            }
        })
        .collect()
}

pub fn save_thread(thread: &SavedThread) {
    let Some(dir) = threads_dir() else { return };
    let path = dir.join(format!("{}.json", thread.id));
    match serde_json::to_vec(thread) {
        Ok(bytes) => {
            if let Err(err) = write_atomically(&path, &bytes) {
                eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
            }
        }
        Err(err) => eprintln!("vectrum-code: couldn't save thread {}: {err}", thread.id),
    }
}

pub fn delete_thread(id: &str) {
    if let Some(dir) = threads_dir() {
        let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
    }
}