use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use crate::fsutil::{atomic_write_0600, ensure_dir_0700};
use crate::paths::cache_dir;
pub const DEFAULT_AGENT_MAX_BYTES: usize = 65_536;
pub const PREVIEW_BYTES: usize = 512;
pub const DEFAULT_SPOOL_TTL_SECS: u64 = 86_400;
pub fn spool_dir() -> PathBuf {
cache_dir().join("spool")
}
fn extension_for(kind: &str) -> &'static str {
match kind {
"toon" => "toon",
"txt" => "txt",
"bin" => "bin",
_ => "json",
}
}
pub fn write_spool(bytes: &[u8], kind: &str) -> Result<PathBuf> {
let dir = spool_dir();
ensure_dir_0700(&dir)?;
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hex::encode(hasher.finalize());
let short = &digest[..12.min(digest.len())];
let path = dir.join(format!("{secs}_{short}.{}", extension_for(kind)));
atomic_write_0600(&path, bytes)?;
Ok(path)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub fn pointer_json(path: &Path, bytes: &[u8], preview_src: &str) -> Value {
let preview: String = preview_src.chars().take(PREVIEW_BYTES).collect();
let path_str = path.display().to_string();
json!({
"spooled": true,
"path": path_str,
"bytes": bytes.len(),
"sha256": sha256_hex(bytes),
"preview": preview,
"hint": format!("rg -n PATTERN '{path_str}'"),
})
}
pub fn clean_spool(ttl_secs: u64) -> Result<usize> {
let dir = spool_dir();
if !dir.is_dir() {
return Ok(0);
}
let now = SystemTime::now();
let mut removed = 0usize;
for entry in std::fs::read_dir(&dir).map_err(Error::from)? {
let entry = entry.map_err(Error::from)?;
let path = entry.path();
if !path.is_file() {
continue;
}
let meta = entry.metadata().map_err(Error::from)?;
let age_ok = if ttl_secs == 0 {
true
} else {
match meta.modified() {
Ok(mtime) => now
.duration_since(mtime)
.map(|d| d.as_secs() >= ttl_secs)
.unwrap_or(true),
Err(_) => true,
}
};
if age_ok {
let _ = std::fs::remove_file(&path);
removed += 1;
}
}
Ok(removed)
}
pub fn maybe_clean_expired() {
let _ = clean_spool(DEFAULT_SPOOL_TTL_SECS);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
use tempfile::tempdir;
#[test]
fn write_and_clean() {
let _g = TEST_PATHS_LOCK.lock().unwrap();
let dir = tempdir().unwrap();
set_cache_dir_override(Some(dir.path().to_path_buf()));
let path = write_spool(b"hello needle world", "txt").unwrap();
assert!(path.exists());
let ptr = pointer_json(&path, b"hello needle world", "hello needle world");
assert_eq!(ptr["spooled"], true);
assert!(ptr["path"].as_str().unwrap().contains("spool"));
let n = clean_spool(0).unwrap();
assert!(n >= 1);
set_cache_dir_override(None);
}
}