Skip to main content

toolpath_opencode/
paths.rs

1//! Filesystem layout for opencode state.
2//!
3//! opencode stores everything under `$XDG_DATA_HOME/opencode/`
4//! (`~/.local/share/opencode/` on macOS/Linux). The primary
5//! conversation store is the `opencode.db` SQLite database; per-step
6//! filesystem snapshots live in sibling bare git repositories under
7//! `snapshot/<project-id>/<sha1(worktree)>/`.
8//!
9//! `project.id` is itself the SHA of the repo's first root commit
10//! (`git rev-list --max-parents=0 HEAD`), so a project survives
11//! being moved on disk. The inner snapshot dirname is the SHA-1 of
12//! the absolute worktree path, which means snapshots are keyed by
13//! exact path — moving the worktree orphans old snapshots even
14//! though the session IDs still resolve.
15
16use crate::error::{ConvoError, Result};
17use sha1::{Digest, Sha1};
18use std::path::{Path, PathBuf};
19
20const SNAPSHOT_SUBDIR: &str = "snapshot";
21const DB_FILE: &str = "opencode.db";
22const LOG_SUBDIR: &str = "log";
23
24/// Builder-style resolver over the opencode data directory.
25#[derive(Debug, Clone)]
26pub struct PathResolver {
27    home_dir: Option<PathBuf>,
28    data_dir: Option<PathBuf>,
29}
30
31impl Default for PathResolver {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl PathResolver {
38    pub fn new() -> Self {
39        Self {
40            home_dir: home_dir(),
41            data_dir: None,
42        }
43    }
44
45    pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
46        self.home_dir = Some(home.into());
47        self
48    }
49
50    /// Override the data directory directly (defaults to
51    /// `$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`).
52    pub fn with_data_dir<P: Into<PathBuf>>(mut self, data_dir: P) -> Self {
53        self.data_dir = Some(data_dir.into());
54        self
55    }
56
57    pub fn home_dir(&self) -> Result<&Path> {
58        self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
59    }
60
61    pub fn data_dir(&self) -> Result<PathBuf> {
62        if let Some(d) = &self.data_dir {
63            return Ok(d.clone());
64        }
65        // XDG_DATA_HOME fallback logic mirroring the `xdg-basedir` crate.
66        if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
67            let p = PathBuf::from(xdg).join("opencode");
68            if !p.as_os_str().is_empty() {
69                return Ok(p);
70            }
71        }
72        Ok(self.home_dir()?.join(".local/share/opencode"))
73    }
74
75    pub fn db_path(&self) -> Result<PathBuf> {
76        Ok(self.data_dir()?.join(DB_FILE))
77    }
78
79    pub fn snapshot_root(&self) -> Result<PathBuf> {
80        Ok(self.data_dir()?.join(SNAPSHOT_SUBDIR))
81    }
82
83    pub fn log_dir(&self) -> Result<PathBuf> {
84        Ok(self.data_dir()?.join(LOG_SUBDIR))
85    }
86
87    /// The bare git repository that backs snapshots for a given
88    /// `(project_id, worktree)` pair.
89    ///
90    /// opencode has used two layouts over its lifetime:
91    /// - Current: `snapshot/<project-id>/<sha1(worktree)>/` — one
92    ///   gitdir per `(project, worktree)` pair so forked worktrees
93    ///   get isolated snapshot stores.
94    /// - Older: `snapshot/<project-id>/` — a single gitdir per
95    ///   project regardless of worktree.
96    ///
97    /// Returns the first candidate that exists. If neither exists,
98    /// returns the current-layout path (so the caller's subsequent
99    /// `git2::Repository::open` will produce a clean NotFound error).
100    pub fn snapshot_gitdir(&self, project_id: &str, worktree: &Path) -> Result<PathBuf> {
101        let root = self.snapshot_root()?;
102        let worktree_hash = sha1_hex(worktree.to_string_lossy().as_bytes());
103        let nested = root.join(project_id).join(&worktree_hash);
104        if nested.exists() {
105            return Ok(nested);
106        }
107        let flat = root.join(project_id);
108        if flat.exists() && flat.join("config").exists() {
109            return Ok(flat);
110        }
111        Ok(nested)
112    }
113
114    pub fn exists(&self) -> bool {
115        self.data_dir().map(|p| p.exists()).unwrap_or(false)
116    }
117
118    pub fn db_exists(&self) -> bool {
119        self.db_path().map(|p| p.exists()).unwrap_or(false)
120    }
121}
122
123fn home_dir() -> Option<PathBuf> {
124    std::env::var_os("HOME")
125        .or_else(|| std::env::var_os("USERPROFILE"))
126        .map(PathBuf::from)
127}
128
129pub(crate) fn sha1_hex(bytes: &[u8]) -> String {
130    let mut h = Sha1::new();
131    h.update(bytes);
132    let digest = h.finalize();
133    let mut out = String::with_capacity(40);
134    for b in digest {
135        use std::fmt::Write;
136        let _ = write!(out, "{:02x}", b);
137    }
138    out
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::fs;
145    use tempfile::TempDir;
146
147    fn setup() -> (TempDir, PathResolver) {
148        let temp = TempDir::new().unwrap();
149        let data = temp.path().join(".local/share/opencode");
150        fs::create_dir_all(&data).unwrap();
151        let resolver = PathResolver::new()
152            .with_home(temp.path())
153            .with_data_dir(&data);
154        (temp, resolver)
155    }
156
157    #[test]
158    fn data_dir_defaults_to_home_when_no_xdg() {
159        let temp = TempDir::new().unwrap();
160        // SAFETY: tests that mutate env vars serialize via the lock in
161        // src/reader.rs tests; the direct test below doesn't mutate.
162        let r = PathResolver::new().with_home(temp.path());
163        let d = r.data_dir().unwrap();
164        assert!(d.ends_with(".local/share/opencode"), "got {:?}", d);
165    }
166
167    #[test]
168    fn db_path_under_data_dir() {
169        let (_t, r) = setup();
170        assert!(r.db_path().unwrap().ends_with("opencode/opencode.db"));
171    }
172
173    #[test]
174    fn snapshot_gitdir_uses_sha1_of_worktree() {
175        let (_t, r) = setup();
176        let pid = "4e82d608d080e9d92be51e24b592302df6a8cbf8";
177        let wt = Path::new("/Users/ben/empathic/oss/toolpath");
178        let gd = r.snapshot_gitdir(pid, wt).unwrap();
179        // sha1("/Users/ben/empathic/oss/toolpath") = bb93f39a…
180        assert!(gd.to_string_lossy().contains(pid));
181        assert!(
182            gd.to_string_lossy()
183                .contains("bb93f39a69862ba18e7893cc96424f83876a9687")
184        );
185    }
186
187    #[test]
188    fn sha1_of_known_string() {
189        assert_eq!(
190            sha1_hex(b"/Users/ben/empathic/oss/toolpath"),
191            "bb93f39a69862ba18e7893cc96424f83876a9687"
192        );
193    }
194
195    #[test]
196    fn exists_reflects_data_dir() {
197        let (_t, r) = setup();
198        assert!(r.exists());
199        let missing = PathResolver::new().with_data_dir("/never/exists");
200        assert!(!missing.exists());
201    }
202}