lantern 0.2.3

Local-first, provenance-aware semantic search for agent activity
Documentation
//! Snapshot the current store to a dated tar.gz archive.
//!
//! `stash` writes a timestamped archive containing a self-consistent copy
//! of `lantern.db` into `<store>/stashes/`. Consistency is achieved by
//! running `PRAGMA wal_checkpoint(TRUNCATE)` beforehand, which folds any
//! pending WAL pages into the main database file, so the archive contains
//! the complete committed state without needing separate wal/shm sidecars.
//!
//! The operation never modifies the store's catalog tables, so every other
//! Lantern command keeps working normally afterwards.

use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use flate2::Compression;
use flate2::write::GzEncoder;
use serde::Serialize;
use tar::Builder;

use crate::store::Store;

const STASH_SUBDIR: &str = "stashes";

#[derive(Debug, Clone, Serialize)]
pub struct StashReport {
    pub archive_path: String,
    pub archive_bytes: u64,
    pub files: Vec<String>,
    pub created_at: i64,
}

pub fn stash(store: &mut Store) -> Result<StashReport> {
    // Flush any pending WAL pages into the main db file so the archive is
    // consistent. `wal_checkpoint(TRUNCATE)` is a no-op when WAL is not
    // active, so this is safe regardless of journaling mode.
    store
        .conn()
        .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
        .context("checkpointing WAL before stash")?;

    let stash_dir = store.root().join(STASH_SUBDIR);
    fs::create_dir_all(&stash_dir)
        .with_context(|| format!("creating stash dir {}", stash_dir.display()))?;

    let ts_str: String =
        store
            .conn()
            .query_row("SELECT strftime('%Y%m%dT%H%M%SZ', 'now')", [], |row| {
                row.get(0)
            })?;
    let archive_path = unique_archive_path(&stash_dir, &ts_str);

    let db_path = store.db_path();
    {
        let out = fs::File::create(&archive_path)
            .with_context(|| format!("creating archive {}", archive_path.display()))?;
        let gz = GzEncoder::new(out, Compression::default());
        let mut tar = Builder::new(gz);
        tar.append_path_with_name(&db_path, "lantern.db")
            .with_context(|| format!("adding {} to archive", db_path.display()))?;
        // Finishing the builder flushes tar headers; GzEncoder is then
        // finished explicitly so any compression state is committed to disk.
        let gz = tar.into_inner()?;
        gz.finish()?;
    }

    let archive_bytes = fs::metadata(&archive_path)
        .with_context(|| format!("stat {}", archive_path.display()))?
        .len();

    Ok(StashReport {
        archive_path: archive_path.to_string_lossy().into_owned(),
        archive_bytes,
        files: vec!["lantern.db".to_string()],
        created_at: now_unix(),
    })
}

fn unique_archive_path(stash_dir: &std::path::Path, ts_str: &str) -> PathBuf {
    let first = stash_dir.join(format!("lantern-{ts_str}.tar.gz"));
    if !first.exists() {
        return first;
    }
    let mut n = 1;
    loop {
        let candidate = stash_dir.join(format!("lantern-{ts_str}-{n}.tar.gz"));
        if !candidate.exists() {
            return candidate;
        }
        n += 1;
    }
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

pub fn print_text(report: &StashReport) {
    println!(
        "stashed archive={} bytes={} files={}",
        report.archive_path,
        report.archive_bytes,
        report.files.join(","),
    );
}

pub fn print_json(report: &StashReport) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(report)?);
    Ok(())
}