Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 72 additions & 80 deletions crates/path-cli/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use anyhow::{Context, Result, anyhow, bail};
use std::path::PathBuf;
use toolpath::v1::Graph;

use crate::config::config_dir;
use std::path::Path;

/// An entry surfaced by `list_cached`.
#[derive(Debug, Clone)]
Expand All @@ -21,16 +21,16 @@ pub(crate) struct CacheEntry {
}

/// The cache directory: `$CONFIG_DIR/documents/`.
pub(crate) fn cache_dir() -> Result<PathBuf> {
Ok(config_dir()?.join(crate::config::DOCUMENTS_DIR_NAME))
pub(crate) fn cache_dir(config_dir: &Path) -> PathBuf {
config_dir.join(crate::config::DOCUMENTS_DIR_NAME)
}

/// Path for a given cache id (does not check existence).
pub(crate) fn cache_path(id: &str) -> Result<PathBuf> {
pub(crate) fn cache_path(config_dir: &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")))
Ok(cache_dir(config_dir).join(format!("{id}.json")))
}

/// Write a toolpath document to the cache under `id`. Errors if the
Expand All @@ -39,18 +39,23 @@ pub(crate) fn cache_path(id: &str) -> Result<PathBuf> {
/// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the
/// exists-check and the write are atomic — two concurrent `path import`
/// invocations racing the same id can't silently stomp each other.
pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result<PathBuf> {
pub(crate) fn write_cached(
config_dir: &Path,
id: &str,
doc: &Graph,
force: bool,
) -> Result<PathBuf> {
use std::io::Write;

let dir = cache_dir()?;
let dir = cache_dir(config_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 path = cache_path(config_dir, id)?;
let json = doc.to_json_pretty()?;

let mut opts = std::fs::OpenOptions::new();
Expand Down Expand Up @@ -87,7 +92,7 @@ pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result<PathBuf
/// Resolve a `<ref>` string to a filesystem path. A ref is either a
/// bare cache id (looks up `$CACHE_DIR/<ref>.json`) or a file path
/// (contains `/` or `\\`, or ends with `.json`).
pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
pub(crate) fn cache_ref(config_dir: &Path, s: &str) -> Result<PathBuf> {
if s.contains('/') || s.contains('\\') || s.ends_with(".json") {
let p = PathBuf::from(s);
if !p.exists() {
Expand All @@ -98,7 +103,7 @@ pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
}
return Ok(p);
}
let p = cache_path(s)?;
let p = cache_path(config_dir, s)?;
if !p.exists() {
bail!(
"cache entry {s} not found at {}; run `path cache ls` to see what's cached",
Expand All @@ -108,8 +113,8 @@ pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
Ok(p)
}

pub(crate) fn list_cached() -> Result<Vec<CacheEntry>> {
let dir = cache_dir()?;
pub(crate) fn list_cached(config_dir: &Path) -> Result<Vec<CacheEntry>> {
let dir = cache_dir(config_dir);
if !dir.exists() {
return Ok(Vec::new());
}
Expand All @@ -136,8 +141,8 @@ pub(crate) fn list_cached() -> Result<Vec<CacheEntry>> {
Ok(out)
}

pub(crate) fn remove_cached(id: &str) -> Result<()> {
let path = cache_path(id)?;
pub(crate) fn remove_cached(config_dir: &Path, id: &str) -> Result<()> {
let path = cache_path(config_dir, id)?;
if !path.exists() {
return Err(anyhow!("cache entry {id} not found"));
}
Expand Down Expand Up @@ -173,19 +178,11 @@ pub(crate) fn pathbase_cache_id(owner: &str, repo: &str, id: &str) -> String {
#[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
/// A config directory in a fresh tempdir. Dropping the `TempDir`
/// removes it.
fn config_dir_in_tempdir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}

fn sample_doc() -> Graph {
Expand All @@ -194,100 +191,94 @@ mod tests {

#[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");
});
let temp = config_dir_in_tempdir();
let doc = sample_doc();
let p = write_cached(temp.path(), "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"));
});
let temp = config_dir_in_tempdir();
let doc = sample_doc();
write_cached(temp.path(), "claude-abc", &doc, false).unwrap();
let err = write_cached(temp.path(), "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();
});
let temp = config_dir_in_tempdir();
let doc = sample_doc();
write_cached(temp.path(), "claude-abc", &doc, false).unwrap();
write_cached(temp.path(), "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);
});
let temp = config_dir_in_tempdir();
let doc = sample_doc();
let p = write_cached(temp.path(), "claude-abc", &doc, false).unwrap();
let resolved = cache_ref(temp.path(), "claude-abc").unwrap();
assert_eq!(resolved, p);
}

#[test]
fn cache_ref_returns_file_path_unchanged() {
let temp = config_dir_in_tempdir();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "{}").unwrap();
let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap();
let resolved = cache_ref(temp.path(), 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"));
});
let temp = config_dir_in_tempdir();
let err = cache_ref(temp.path(), "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());
let temp = config_dir_in_tempdir();
assert!(cache_path(temp.path(), "foo/bar").is_err());
assert!(cache_path(temp.path(), "foo.json").is_err());
assert!(cache_path(temp.path(), "").is_err());
}

#[test]
fn list_empty_when_dir_missing() {
with_cfg(|_| {
assert!(list_cached().unwrap().is_empty());
});
let temp = config_dir_in_tempdir();
assert!(list_cached(temp.path()).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());
});
let temp = config_dir_in_tempdir();
let doc = sample_doc();
write_cached(temp.path(), "a", &doc, false).unwrap();
write_cached(temp.path(), "b", &doc, false).unwrap();
let entries = list_cached(temp.path()).unwrap();
assert_eq!(entries.len(), 2);

remove_cached(temp.path(), "a").unwrap();
let entries = list_cached(temp.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "b");

assert!(remove_cached(temp.path(), "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);
});
let temp = config_dir_in_tempdir();
let p = write_cached(temp.path(), "claude-abc", &sample_doc(), false).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}

#[test]
Expand All @@ -306,7 +297,8 @@ mod tests {
#[test]
fn make_id_result_survives_cache_path() {
// Regression: make_id output must be accepted by cache_path.
let temp = config_dir_in_tempdir();
let id = make_id("pathbase", "trc_01H.json");
assert!(cache_path(&id).is_ok());
assert!(cache_path(temp.path(), &id).is_ok());
}
}
20 changes: 8 additions & 12 deletions crates/path-cli/src/cmd_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use anyhow::Result;
use clap::Subcommand;
#[cfg(not(target_os = "emscripten"))]
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use crate::cache::{list_cached, remove_cached};
use crate::config::Config;
Expand Down Expand Up @@ -46,8 +46,8 @@ pub enum CacheOp {

pub fn run(op: CacheOp, config: &Config) -> Result<()> {
match op {
CacheOp::Ls => run_ls(),
CacheOp::Rm { id } => run_rm(&id, config),
CacheOp::Ls => run_ls(&config.config_dir()?),
CacheOp::Rm { id } => run_rm(&id, &config.config_dir()?),
#[cfg(not(target_os = "emscripten"))]
CacheOp::Sync {
types,
Expand All @@ -56,8 +56,8 @@ pub fn run(op: CacheOp, config: &Config) -> Result<()> {
}
}

fn run_ls() -> Result<()> {
let entries = list_cached()?;
fn run_ls(config_dir: &Path) -> Result<()> {
let entries = list_cached(config_dir)?;
if entries.is_empty() {
eprintln!("No cached documents. Run `path import <source>` to create one.");
return Ok(());
Expand All @@ -68,16 +68,12 @@ fn run_ls() -> Result<()> {
Ok(())
}

#[cfg_attr(target_os = "emscripten", expect(unused_variables))]
fn run_rm(id: &str, config: &Config) -> Result<()> {
remove_cached(id)?;
fn run_rm(id: &str, config_dir: &Path) -> Result<()> {
remove_cached(config_dir, id)?;
// The artifact is still real — downgrade its manifest record to
// "known, not cached" so the next sync can re-materialize it.
#[cfg(not(target_os = "emscripten"))]
if let Err(e) = config
.config_dir()
.and_then(|dir| crate::sync::evict_cache_id(&dir, id))
{
if let Err(e) = crate::sync::evict_cache_id(config_dir, id) {
eprintln!("warning: sync manifest not updated: {e}");
}
eprintln!("Removed {id}");
Expand Down
12 changes: 6 additions & 6 deletions crates/path-cli/src/cmd_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::ffi::OsString;
use std::io::Write;
use std::path::Path;

use crate::config::{CONFIG_FILE_NAME, config_dir, home_dir, home_relative};
use crate::config::{CONFIG_FILE_NAME, Config, home_relative};

#[derive(Subcommand, Debug)]
pub enum ConfigOp {
Expand All @@ -21,24 +21,24 @@ pub enum ConfigOp {
Edit,
}

pub fn run(op: ConfigOp) -> Result<()> {
pub fn run(op: ConfigOp, config: &Config) -> Result<()> {
match op {
ConfigOp::Edit => edit(),
ConfigOp::Edit => edit(config),
}
}

/// Written on first `path config edit`. Comments only — a fresh file
/// behaves exactly like no file.
const TEMPLATE: &str = "# Toolpath user configuration.\n# https://toolpath.net/cli/\n";

fn edit() -> Result<()> {
let path = config_dir()?.join(CONFIG_FILE_NAME);
fn edit(config: &Config) -> Result<()> {
let path = config.config_dir()?.join(CONFIG_FILE_NAME);
ensure_config_file(&path)?;

let editor = resolve_editor(std::env::var_os("VISUAL"), std::env::var_os("EDITOR"));
run_editor(&editor, &path)?;

let display = home_relative(&path, home_dir().as_deref());
let display = home_relative(&path, config.home_dir().map(|p| p.as_path()));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {display} back after editing"))?;
let rules = crate::share_config::validate_config_text(&text, &display).with_context(|| {
Expand Down
Loading
Loading