path-cli 0.26.0

CLI for deriving, querying, and visualizing Toolpath provenance (binary: path)
Documentation
//! Shared helpers for `path resume` integration tests.
//!
//! These are NOT integration-test entry points — they're a support
//! module imported by `tests/resume.rs`. Lives under `tests/` so it
//! doesn't leak into the production library API.

#![allow(dead_code)]

use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

use path_cli::cmd_resume::ResumeArgs;
use path_cli::harness::Harness;

/// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or
/// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs`
/// can't reach the library's internal `crate::config::TEST_ENV_LOCK`,
/// so we use a separate lock here. Crucially, no library test holds
/// this lock — but library tests now properly save+restore env vars
/// (see commit 23deeb2), so the integration suite can be self-isolating.
pub fn env_lock() -> std::sync::MutexGuard<'static, ()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
        .lock()
        .unwrap_or_else(|e| e.into_inner())
}

/// RAII guard that pins `$HOME`, `$TOOLPATH_CONFIG_DIR`, and
/// `$XDG_DATA_HOME` to a tempdir, and clears `$CLAUDE_CONFIG_DIR`.
///
/// `$XDG_DATA_HOME` matters because the opencode `PathResolver` prefers it
/// over `$HOME` when locating `opencode.db`. Without pinning it, a machine
/// that sets `$XDG_DATA_HOME` (common on Linux; also set on this dev box)
/// would send the opencode projector to the *real* user database — the
/// test would fail with "table … already exists" and, worse, mutate the
/// user's live opencode data. Pinning it keeps every harness sandboxed.
///
/// `$CLAUDE_CONFIG_DIR` is *cleared* rather than pinned. The Claude reader
/// prefers it over `$HOME/.claude`, so on a machine that exports it — every
/// Claude Code user, since the variable is how a custom config root is
/// selected at all — pinning `$HOME` no longer sandboxes anything: the CLI
/// walks out to the developer's real config and reports the fixture session
/// missing. Clearing it puts the reader back on its `$HOME`-relative
/// default, which is the sandbox. A test that wants to exercise the override
/// should set the variable deliberately, itself.
pub struct ScopedHome {
    _td: tempfile::TempDir,
    prev_home: Option<OsString>,
    prev_config: Option<OsString>,
    prev_xdg_data: Option<OsString>,
    prev_claude_config: Option<OsString>,
}

impl ScopedHome {
    pub fn new() -> Self {
        let td = tempfile::tempdir().unwrap();
        let prev_home = std::env::var_os("HOME");
        let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR");
        let prev_xdg_data = std::env::var_os("XDG_DATA_HOME");
        let prev_claude_config = std::env::var_os("CLAUDE_CONFIG_DIR");
        unsafe {
            std::env::set_var("HOME", td.path());
            std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath"));
            std::env::set_var("XDG_DATA_HOME", td.path().join(".local/share"));
            std::env::remove_var("CLAUDE_CONFIG_DIR");
        }
        Self {
            _td: td,
            prev_home,
            prev_config,
            prev_xdg_data,
            prev_claude_config,
        }
    }

    pub fn home_dir(&self) -> PathBuf {
        PathBuf::from(self._td.path())
    }
}

impl Drop for ScopedHome {
    fn drop(&mut self) {
        unsafe {
            match &self.prev_home {
                Some(v) => std::env::set_var("HOME", v),
                None => std::env::remove_var("HOME"),
            }
            match &self.prev_config {
                Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v),
                None => std::env::remove_var("TOOLPATH_CONFIG_DIR"),
            }
            match &self.prev_xdg_data {
                Some(v) => std::env::set_var("XDG_DATA_HOME", v),
                None => std::env::remove_var("XDG_DATA_HOME"),
            }
            match &self.prev_claude_config {
                Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v),
                None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
            }
        }
    }
}

/// RAII guard that prepends a tempdir of fake binaries to `$PATH`.
pub struct ScopedPath {
    _td: tempfile::TempDir,
    prev: Option<OsString>,
}

impl ScopedPath {
    pub fn with_binary(name: &str) -> Self {
        Self::with_binaries(&[name])
    }

    pub fn with_binaries(names: &[&str]) -> Self {
        let td = tempfile::tempdir().unwrap();
        for n in names {
            let p = td.path().join(n);
            std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let mut perm = std::fs::metadata(&p).unwrap().permissions();
                perm.set_mode(0o755);
                std::fs::set_permissions(&p, perm).unwrap();
            }
        }
        let prev = std::env::var_os("PATH");
        let new_path = std::env::join_paths(
            std::iter::once(td.path().to_path_buf())
                .chain(std::env::split_paths(&prev.clone().unwrap_or_default())),
        )
        .unwrap();
        unsafe {
            std::env::set_var("PATH", new_path);
        }
        Self { _td: td, prev }
    }

    pub fn empty() -> Self {
        let td = tempfile::tempdir().unwrap();
        let prev = std::env::var_os("PATH");
        unsafe {
            std::env::set_var("PATH", td.path());
        }
        Self { _td: td, prev }
    }
}

impl Drop for ScopedPath {
    fn drop(&mut self) {
        unsafe {
            match &self.prev {
                Some(v) => std::env::set_var("PATH", v),
                None => std::env::remove_var("PATH"),
            }
        }
    }
}

/// Build a minimal `Path` whose single step has the given `actor`
/// and a `conversation.append` artifact keyed `<artifact_prefix>://<session>`.
/// The artifact key drives the harness projector's session-id extraction;
/// the actor satisfies `require_an_agent_turn`.
pub fn make_convo_path(actor: &str, artifact_key: &str) -> toolpath::v1::Path {
    let mut extra = HashMap::new();
    extra.insert("role".to_string(), serde_json::json!("user"));
    extra.insert("text".to_string(), serde_json::json!("hello"));
    let step = toolpath::v1::Step {
        step: toolpath::v1::StepIdentity {
            id: "s1".to_string(),
            parents: vec![],
            actor: actor.to_string(),
            timestamp: "2026-01-01T00:00:00Z".to_string(),
        },
        change: {
            let mut m = HashMap::new();
            m.insert(
                artifact_key.to_string(),
                toolpath::v1::ArtifactChange {
                    raw: None,
                    structural: Some(toolpath::v1::StructuralChange {
                        change_type: "conversation.append".to_string(),
                        extra,
                    }),
                },
            );
            m
        },
        meta: None,
    };
    toolpath::v1::Path {
        path: toolpath::v1::PathIdentity {
            id: "p1".to_string(),
            base: None,
            head: "s1".to_string(),
            graph_ref: None,
        },
        steps: vec![step],
        meta: None,
    }
}

/// Convenience: write a single-path graph as JSON to `dir/doc.json`.
pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf {
    let graph = toolpath::v1::Graph::from_path(path);
    let p = dir.join("doc.json");
    std::fs::write(&p, graph.to_json().unwrap()).unwrap();
    p
}

/// Construct `ResumeArgs` for a file-input + explicit-harness test.
pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs {
    ResumeArgs {
        input: input.to_string_lossy().to_string(),
        cwd: Some(cwd.to_path_buf()),
        harness: Some(harness),
        ..Default::default()
    }
}

/// Recursively walk `root` looking for a file with the given extension.
pub fn dir_contains_file_with_ext(root: &Path, ext: &str) -> bool {
    fn walk(p: &Path, ext: &str) -> bool {
        if !p.exists() {
            return false;
        }
        if p.is_dir() {
            for e in std::fs::read_dir(p).unwrap() {
                if walk(&e.unwrap().path(), ext) {
                    return true;
                }
            }
            false
        } else {
            p.extension().and_then(|s| s.to_str()) == Some(ext)
        }
    }
    walk(root, ext)
}