use anyhow::{Context, Result, anyhow, bail};
use clap::Subcommand;
use std::path::PathBuf;
use toolpath::v1::Document;
use crate::config::config_dir;
const DOCUMENTS_DIR: &str = "documents";
#[derive(Subcommand, Debug)]
pub enum CacheOp {
Ls,
Rm {
id: String,
},
}
pub fn run(op: CacheOp) -> Result<()> {
match op {
CacheOp::Ls => run_ls(),
CacheOp::Rm { id } => run_rm(&id),
}
}
fn run_ls() -> Result<()> {
let entries = list_cached()?;
if entries.is_empty() {
eprintln!("No cached documents. Run `path import <source>` to create one.");
return Ok(());
}
for e in entries {
println!("{}\t{}\t{}", e.id, e.bytes, e.path.display());
}
Ok(())
}
fn run_rm(id: &str) -> Result<()> {
remove_cached(id)?;
eprintln!("Removed {id}");
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) struct CacheEntry {
pub id: String,
pub path: PathBuf,
pub bytes: u64,
pub modified: std::time::SystemTime,
}
pub(crate) fn cache_dir() -> Result<PathBuf> {
Ok(config_dir()?.join(DOCUMENTS_DIR))
}
pub(crate) fn cache_path(id: &str) -> Result<PathBuf> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.ends_with(".json") {
bail!("invalid cache id: {id:?}");
}
Ok(cache_dir()?.join(format!("{id}.json")))
}
pub(crate) fn write_cached(id: &str, doc: &Document, force: bool) -> Result<PathBuf> {
use std::io::Write;
let dir = cache_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
let path = cache_path(id)?;
let json = doc.to_json_pretty()?;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).truncate(true);
if force {
opts.create(true);
} else {
opts.create_new(true);
}
let mut file = match opts.open(&path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
bail!(
"cache entry {id} already exists at {}; pass --force to overwrite",
path.display()
);
}
Err(e) => {
return Err(anyhow!("open {}: {e}", path.display()));
}
};
file.write_all(json.as_bytes())
.with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 0600 {}", path.display()))?;
}
Ok(path)
}
pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
if s.contains('/') || s.contains('\\') || s.ends_with(".json") {
let p = PathBuf::from(s);
if !p.exists() {
bail!(
"file not found: {}; if you meant a cache id, drop the path/extension and run `path cache ls`",
p.display()
);
}
return Ok(p);
}
let p = cache_path(s)?;
if !p.exists() {
bail!(
"cache entry {s} not found at {}; run `path cache ls` to see what's cached",
p.display()
);
}
Ok(p)
}
pub(crate) fn list_cached() -> Result<Vec<CacheEntry>> {
let dir = cache_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let id = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let meta = entry.metadata()?;
out.push(CacheEntry {
id,
path,
bytes: meta.len(),
modified: meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH),
});
}
out.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(out)
}
pub(crate) fn remove_cached(id: &str) -> Result<()> {
let path = cache_path(id)?;
if !path.exists() {
return Err(anyhow!("cache entry {id} not found"));
}
std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
Ok(())
}
pub(crate) fn make_id(source: &str, inner: &str) -> String {
let trimmed = inner.trim_end_matches(".json");
let safe: String = trimmed
.chars()
.map(|c| match c {
'/' | '\\' | ':' | ' ' | '\t' => '_',
c => c,
})
.collect();
format!("{source}-{safe}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK};
fn with_cfg<F: FnOnce(&std::path::Path) -> R, R>(f: F) -> R {
let temp = tempfile::tempdir().unwrap();
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(CONFIG_DIR_ENV, temp.path());
}
let result = f(temp.path());
unsafe {
std::env::remove_var(CONFIG_DIR_ENV);
}
result
}
fn sample_doc() -> Document {
Document::Step(toolpath::v1::Step::new(
"s1",
"human:alex",
"2026-01-01T00:00:00Z",
))
}
#[test]
fn write_and_read_cache_entry() {
with_cfg(|_| {
let doc = sample_doc();
let p = write_cached("claude-abc", &doc, false).unwrap();
assert!(p.exists());
assert_eq!(p.file_name().unwrap(), "claude-abc.json");
});
}
#[test]
fn write_errors_if_exists_without_force() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("claude-abc", &doc, false).unwrap();
let err = write_cached("claude-abc", &doc, false).unwrap_err();
assert!(err.to_string().contains("already exists"));
});
}
#[test]
fn write_force_overwrites() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("claude-abc", &doc, false).unwrap();
write_cached("claude-abc", &doc, true).unwrap();
});
}
#[test]
fn cache_ref_finds_existing_cache_entry() {
with_cfg(|_| {
let doc = sample_doc();
let p = write_cached("claude-abc", &doc, false).unwrap();
let resolved = cache_ref("claude-abc").unwrap();
assert_eq!(resolved, p);
});
}
#[test]
fn cache_ref_returns_file_path_unchanged() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "{}").unwrap();
let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(resolved, tmp.path());
}
#[test]
fn cache_ref_errors_on_missing_id() {
with_cfg(|_| {
let err = cache_ref("does-not-exist").unwrap_err();
assert!(err.to_string().contains("not found"));
});
}
#[test]
fn cache_path_rejects_slashes_and_json_suffix() {
assert!(cache_path("foo/bar").is_err());
assert!(cache_path("foo.json").is_err());
assert!(cache_path("").is_err());
}
#[test]
fn list_empty_when_dir_missing() {
with_cfg(|_| {
assert!(list_cached().unwrap().is_empty());
});
}
#[test]
fn list_and_remove_roundtrip() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("a", &doc, false).unwrap();
write_cached("b", &doc, false).unwrap();
let entries = list_cached().unwrap();
assert_eq!(entries.len(), 2);
remove_cached("a").unwrap();
let entries = list_cached().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "b");
assert!(remove_cached("a").is_err());
});
}
#[cfg(unix)]
#[test]
fn writes_file_with_0600() {
use std::os::unix::fs::PermissionsExt;
with_cfg(|_| {
let p = write_cached("claude-abc", &sample_doc(), false).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
});
}
#[test]
fn make_id_sanitizes_slashes() {
assert_eq!(make_id("git", "main"), "git-main");
assert_eq!(make_id("git", "feature/x"), "git-feature_x");
assert_eq!(make_id("pathbase", "trc_01H"), "pathbase-trc_01H");
}
#[test]
fn make_id_strips_trailing_json() {
assert_eq!(make_id("pathbase", "trc_01H.json"), "pathbase-trc_01H");
assert_eq!(make_id("git", "path-main.json"), "git-path-main");
}
#[test]
fn make_id_result_survives_cache_path() {
let id = make_id("pathbase", "trc_01H.json");
assert!(cache_path(&id).is_ok());
}
}