1use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde_json::{json, Value};
7use sha2::{Digest, Sha256};
8
9use crate::error::{Error, Result};
10use crate::fsutil::{atomic_write_0600, ensure_dir_0700};
11use crate::paths::cache_dir;
12
13pub const DEFAULT_AGENT_MAX_BYTES: usize = 65_536;
15
16pub const PREVIEW_BYTES: usize = 512;
18
19pub const DEFAULT_SPOOL_TTL_SECS: u64 = 86_400;
21
22pub fn spool_dir() -> PathBuf {
23 cache_dir().join("spool")
24}
25
26fn extension_for(kind: &str) -> &'static str {
27 match kind {
28 "toon" => "toon",
29 "txt" => "txt",
30 "bin" => "bin",
31 _ => "json",
32 }
33}
34
35pub fn write_spool(bytes: &[u8], kind: &str) -> Result<PathBuf> {
37 let dir = spool_dir();
38 ensure_dir_0700(&dir)?;
39 let secs = SystemTime::now()
40 .duration_since(UNIX_EPOCH)
41 .map(|d| d.as_secs())
42 .unwrap_or(0);
43 let mut hasher = Sha256::new();
44 hasher.update(bytes);
45 let digest = hex::encode(hasher.finalize());
46 let short = &digest[..12.min(digest.len())];
47 let path = dir.join(format!("{secs}_{short}.{}", extension_for(kind)));
48 atomic_write_0600(&path, bytes)?;
49 Ok(path)
50}
51
52pub fn sha256_hex(bytes: &[u8]) -> String {
53 let mut hasher = Sha256::new();
54 hasher.update(bytes);
55 hex::encode(hasher.finalize())
56}
57
58pub fn pointer_json(path: &Path, bytes: &[u8], preview_src: &str) -> Value {
60 let preview: String = preview_src.chars().take(PREVIEW_BYTES).collect();
61 let path_str = path.display().to_string();
62 json!({
63 "spooled": true,
64 "path": path_str,
65 "bytes": bytes.len(),
66 "sha256": sha256_hex(bytes),
67 "preview": preview,
68 "hint": format!("rg -n PATTERN '{path_str}'"),
69 })
70}
71
72pub fn clean_spool(ttl_secs: u64) -> Result<usize> {
74 let dir = spool_dir();
75 if !dir.is_dir() {
76 return Ok(0);
77 }
78 let now = SystemTime::now();
79 let mut removed = 0usize;
80 for entry in std::fs::read_dir(&dir).map_err(Error::from)? {
81 let entry = entry.map_err(Error::from)?;
82 let path = entry.path();
83 if !path.is_file() {
84 continue;
85 }
86 let meta = entry.metadata().map_err(Error::from)?;
87 let age_ok = if ttl_secs == 0 {
88 true
89 } else {
90 match meta.modified() {
91 Ok(mtime) => now
92 .duration_since(mtime)
93 .map(|d| d.as_secs() >= ttl_secs)
94 .unwrap_or(true),
95 Err(_) => true,
96 }
97 };
98 if age_ok {
99 let _ = std::fs::remove_file(&path);
100 removed += 1;
101 }
102 }
103 Ok(removed)
104}
105
106pub fn maybe_clean_expired() {
108 let _ = clean_spool(DEFAULT_SPOOL_TTL_SECS);
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
115 use tempfile::tempdir;
116
117 #[test]
118 fn write_and_clean() {
119 let _g = TEST_PATHS_LOCK.lock().unwrap();
120 let dir = tempdir().unwrap();
121 set_cache_dir_override(Some(dir.path().to_path_buf()));
122 let path = write_spool(b"hello needle world", "txt").unwrap();
123 assert!(path.exists());
124 let ptr = pointer_json(&path, b"hello needle world", "hello needle world");
125 assert_eq!(ptr["spooled"], true);
126 assert!(ptr["path"].as_str().unwrap().contains("spool"));
127 let n = clean_spool(0).unwrap();
128 assert!(n >= 1);
129 set_cache_dir_override(None);
130 }
131}