use crate::error::{ConvoError, Result};
use sha1::{Digest, Sha1};
use std::path::{Path, PathBuf};
const SNAPSHOT_SUBDIR: &str = "snapshot";
const DB_FILE: &str = "opencode.db";
const LOG_SUBDIR: &str = "log";
#[derive(Debug, Clone)]
pub struct PathResolver {
home_dir: Option<PathBuf>,
data_dir: Option<PathBuf>,
}
impl Default for PathResolver {
fn default() -> Self {
Self::new()
}
}
impl PathResolver {
pub fn new() -> Self {
Self {
home_dir: home_dir(),
data_dir: None,
}
}
pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
self.home_dir = Some(home.into());
self
}
pub fn with_data_dir<P: Into<PathBuf>>(mut self, data_dir: P) -> Self {
self.data_dir = Some(data_dir.into());
self
}
pub fn home_dir(&self) -> Result<&Path> {
self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
}
pub fn data_dir(&self) -> Result<PathBuf> {
if let Some(d) = &self.data_dir {
return Ok(d.clone());
}
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
let p = PathBuf::from(xdg).join("opencode");
if !p.as_os_str().is_empty() {
return Ok(p);
}
}
Ok(self.home_dir()?.join(".local/share/opencode"))
}
pub fn db_path(&self) -> Result<PathBuf> {
Ok(self.data_dir()?.join(DB_FILE))
}
pub fn snapshot_root(&self) -> Result<PathBuf> {
Ok(self.data_dir()?.join(SNAPSHOT_SUBDIR))
}
pub fn log_dir(&self) -> Result<PathBuf> {
Ok(self.data_dir()?.join(LOG_SUBDIR))
}
pub fn snapshot_gitdir(&self, project_id: &str, worktree: &Path) -> Result<PathBuf> {
let root = self.snapshot_root()?;
let worktree_hash = sha1_hex(worktree.to_string_lossy().as_bytes());
let nested = root.join(project_id).join(&worktree_hash);
if nested.exists() {
return Ok(nested);
}
let flat = root.join(project_id);
if flat.exists() && flat.join("config").exists() {
return Ok(flat);
}
Ok(nested)
}
pub fn exists(&self) -> bool {
self.data_dir().map(|p| p.exists()).unwrap_or(false)
}
pub fn db_exists(&self) -> bool {
self.db_path().map(|p| p.exists()).unwrap_or(false)
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub(crate) fn sha1_hex(bytes: &[u8]) -> String {
let mut h = Sha1::new();
h.update(bytes);
let digest = h.finalize();
let mut out = String::with_capacity(40);
for b in digest {
use std::fmt::Write;
let _ = write!(out, "{:02x}", b);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup() -> (TempDir, PathResolver) {
let temp = TempDir::new().unwrap();
let data = temp.path().join(".local/share/opencode");
fs::create_dir_all(&data).unwrap();
let resolver = PathResolver::new()
.with_home(temp.path())
.with_data_dir(&data);
(temp, resolver)
}
#[test]
fn data_dir_defaults_to_home_when_no_xdg() {
let temp = TempDir::new().unwrap();
let r = PathResolver::new().with_home(temp.path());
let d = r.data_dir().unwrap();
assert!(d.ends_with(".local/share/opencode"), "got {:?}", d);
}
#[test]
fn db_path_under_data_dir() {
let (_t, r) = setup();
assert!(r.db_path().unwrap().ends_with("opencode/opencode.db"));
}
#[test]
fn snapshot_gitdir_uses_sha1_of_worktree() {
let (_t, r) = setup();
let pid = "4e82d608d080e9d92be51e24b592302df6a8cbf8";
let wt = Path::new("/Users/ben/empathic/oss/toolpath");
let gd = r.snapshot_gitdir(pid, wt).unwrap();
assert!(gd.to_string_lossy().contains(pid));
assert!(
gd.to_string_lossy()
.contains("bb93f39a69862ba18e7893cc96424f83876a9687")
);
}
#[test]
fn sha1_of_known_string() {
assert_eq!(
sha1_hex(b"/Users/ben/empathic/oss/toolpath"),
"bb93f39a69862ba18e7893cc96424f83876a9687"
);
}
#[test]
fn exists_reflects_data_dir() {
let (_t, r) = setup();
assert!(r.exists());
let missing = PathResolver::new().with_data_dir("/never/exists");
assert!(!missing.exists());
}
}