use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub fn user_data_dir() -> PathBuf {
if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
return PathBuf::from(custom);
}
if let Some(home) = dirs::home_dir() {
return home.join(".recursive");
}
PathBuf::from(".recursive")
}
pub fn user_workspace_dir(workspace: &Path) -> Result<PathBuf> {
let abs = canonicalize_workspace(workspace)?;
let hash = workspace_hash_from_canonical(&abs);
let dir = user_data_dir().join("workspaces").join(&hash);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(Error::Io)?;
let marker = dir.join("path.txt");
if !marker.exists() {
let _ = std::fs::write(&marker, abs.display().to_string());
}
}
Ok(dir)
}
pub fn user_sessions_dir(workspace: &Path) -> Result<PathBuf> {
let dir = user_workspace_dir(workspace)?.join("sessions");
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(Error::Io)?;
}
Ok(dir)
}
pub fn user_shadow_git_dir(workspace: &Path) -> Result<PathBuf> {
let dir = user_workspace_dir(workspace)?.join("shadow-git");
Ok(dir)
}
pub fn user_scratchpad_path(workspace: &Path) -> Result<PathBuf> {
Ok(user_workspace_dir(workspace)?.join("scratchpad.json"))
}
pub fn workspace_hash(workspace: &Path) -> String {
let abs = canonicalize_workspace(workspace).unwrap_or_else(|_| workspace.to_path_buf());
workspace_hash_from_canonical(&abs)
}
fn workspace_hash_from_canonical(abs: &Path) -> String {
let bytes = abs.as_os_str().to_string_lossy();
let hash = blake3::hash(bytes.as_bytes());
hash.to_hex().chars().take(12).collect()
}
fn canonicalize_workspace(workspace: &Path) -> Result<PathBuf> {
if let Ok(abs) = workspace.canonicalize() {
return Ok(abs);
}
let cwd = std::env::current_dir().map_err(Error::Io)?;
Ok(if workspace.is_absolute() {
workspace.to_path_buf()
} else {
cwd.join(workspace)
})
}
pub fn legacy_paths_in_workspace(workspace: &Path) -> Vec<PathBuf> {
let root = workspace.join(".recursive");
if !root.exists() {
return vec![];
}
[
root.join("sessions"),
root.join("shadow-git"),
root.join("scratchpad.json"),
]
.into_iter()
.filter(|p| p.exists())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::PinnedRecursiveHome;
#[test]
fn user_data_dir_honors_env_override() {
let _g = PinnedRecursiveHome::new("/tmp/recursive-test-fixed");
assert_eq!(user_data_dir(), PathBuf::from("/tmp/recursive-test-fixed"));
}
#[test]
fn workspace_hash_is_stable_across_calls() {
let p = Path::new("/tmp/some/path");
assert_eq!(workspace_hash(p), workspace_hash(p));
assert_eq!(workspace_hash(p).len(), 12);
}
#[test]
fn workspace_hash_differs_for_different_paths() {
let a = workspace_hash(Path::new("/tmp/a"));
let b = workspace_hash(Path::new("/tmp/b"));
assert_ne!(a, b);
}
#[test]
fn user_workspace_dir_writes_path_txt() {
let home = tempfile::tempdir().unwrap();
let _g = PinnedRecursiveHome::new(home.path());
let workspace = tempfile::tempdir().unwrap();
let ws_dir = user_workspace_dir(workspace.path()).unwrap();
assert!(ws_dir.exists());
let marker = ws_dir.join("path.txt");
assert!(marker.exists());
let contents = std::fs::read_to_string(&marker).unwrap();
let abs = workspace.path().canonicalize().unwrap();
assert_eq!(contents, abs.display().to_string());
}
#[test]
fn legacy_paths_detects_in_tree_state() {
let workspace = tempfile::tempdir().unwrap();
let dotrec = workspace.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
std::fs::write(dotrec.join("scratchpad.json"), "{}").unwrap();
let found = legacy_paths_in_workspace(workspace.path());
assert_eq!(found.len(), 3, "expected all 3 legacy paths, got {found:?}");
}
#[test]
fn legacy_paths_returns_empty_when_clean() {
let workspace = tempfile::tempdir().unwrap();
assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
}
#[test]
fn legacy_paths_does_not_flag_skills_or_mcp_json() {
let workspace = tempfile::tempdir().unwrap();
let dotrec = workspace.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("skills")).unwrap();
std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
}
}