Skip to main content

toolpath_pi/
paths.rs

1//! Path resolution for Pi's on-disk layout.
2//!
3//! Pi stores session logs at:
4//!
5//! ```text
6//! $HOME/.pi/agent/sessions/--<encoded-cwd>--/<timestamp>_<uuid>.jsonl
7//! ```
8//!
9//! The project directory name encodes the cwd: the leading slash is dropped,
10//! each remaining `/` becomes `-`, and the whole thing is wrapped in
11//! `--...--`. This is lossy for paths that contain internal dashes (Pi has
12//! the same limitation), but we accept the ambiguity for now.
13
14use std::path::{Path, PathBuf};
15
16/// Resolves the Pi sessions directory and its project subdirectories.
17#[derive(Debug, Clone)]
18pub struct PathResolver {
19    home_dir: Option<PathBuf>,
20    sessions_dir_override: Option<PathBuf>,
21    /// Cached effective sessions_dir. Recomputed on `with_home`.
22    sessions_dir: PathBuf,
23}
24
25impl Default for PathResolver {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl PathResolver {
32    /// Default resolver: `$HOME/.pi/agent/sessions/`, falling back to
33    /// `./.pi/agent/sessions` when no home directory is available.
34    pub fn new() -> Self {
35        let home_dir = std::env::var_os("HOME").map(PathBuf::from);
36        let sessions_dir = compute_sessions_dir(home_dir.as_deref(), None);
37        Self {
38            home_dir,
39            sessions_dir_override: None,
40            sessions_dir,
41        }
42    }
43
44    /// Override the home directory (useful for tests). Recomputes sessions_dir
45    /// unless an explicit sessions-dir override is in effect.
46    pub fn with_home(mut self, home: impl AsRef<Path>) -> Self {
47        self.home_dir = Some(home.as_ref().to_path_buf());
48        self.sessions_dir = compute_sessions_dir(
49            self.home_dir.as_deref(),
50            self.sessions_dir_override.as_deref(),
51        );
52        self
53    }
54
55    /// Override the sessions base directory directly.
56    pub fn with_sessions_dir(mut self, dir: impl AsRef<Path>) -> Self {
57        let p = dir.as_ref().to_path_buf();
58        self.sessions_dir_override = Some(p.clone());
59        self.sessions_dir = p;
60        self
61    }
62
63    /// The resolved sessions directory (`.../sessions/`).
64    pub fn sessions_dir(&self) -> &Path {
65        &self.sessions_dir
66    }
67
68    /// Project directory for a given cwd.
69    pub fn project_dir(&self, cwd: &str) -> PathBuf {
70        self.sessions_dir.join(encode_project(cwd))
71    }
72
73    /// Whether the sessions directory exists.
74    pub fn exists(&self) -> bool {
75        self.sessions_dir.exists()
76    }
77
78    /// Return the project cwd → directory-name encoding for `cwd`.
79    pub fn encode_cwd(&self, cwd: &str) -> String {
80        encode_project(cwd)
81    }
82
83    /// Return the cwd decoded from a project directory name.
84    pub fn decode_project_dir(&self, dir_name: &str) -> String {
85        decode_project(dir_name)
86    }
87
88    /// Enumerate project directories that exist on disk. Returns decoded cwd
89    /// strings, sorted ascending. Missing sessions_dir returns an empty vec
90    /// rather than an error.
91    pub fn list_projects(&self) -> std::io::Result<Vec<String>> {
92        if !self.sessions_dir.exists() {
93            return Ok(Vec::new());
94        }
95
96        let mut out = Vec::new();
97        for entry in std::fs::read_dir(&self.sessions_dir)? {
98            let entry = entry?;
99            if !entry.file_type()?.is_dir() {
100                continue;
101            }
102            if let Some(name) = entry.file_name().to_str() {
103                out.push(decode_project(name));
104            }
105        }
106        out.sort();
107        Ok(out)
108    }
109}
110
111fn compute_sessions_dir(home: Option<&Path>, override_dir: Option<&Path>) -> PathBuf {
112    if let Some(o) = override_dir {
113        return o.to_path_buf();
114    }
115    let base = home
116        .map(Path::to_path_buf)
117        .unwrap_or_else(|| PathBuf::from("."));
118    base.join(".pi").join("agent").join("sessions")
119}
120
121/// Encode a cwd path into a Pi project directory name.
122///
123/// ```
124/// # use toolpath_pi::paths::encode_project;
125/// assert_eq!(encode_project("/Users/alex/project"), "--Users-alex-project--");
126/// assert_eq!(encode_project("/"), "----");
127/// assert_eq!(encode_project(""), "----");
128/// ```
129pub fn encode_project(cwd: &str) -> String {
130    let trimmed = cwd.trim_start_matches('/');
131    format!("--{}--", trimmed.replace('/', "-"))
132}
133
134/// Decode a Pi project directory name back into a cwd.
135///
136/// The empty encoding `----` decodes to `/`.
137///
138/// ```
139/// # use toolpath_pi::paths::decode_project;
140/// assert_eq!(decode_project("--Users-alex-project--"), "/Users/alex/project");
141/// assert_eq!(decode_project("----"), "/");
142/// ```
143pub fn decode_project(dir_name: &str) -> String {
144    // Strip exactly one leading and trailing "--" if present.
145    let after_prefix = dir_name.strip_prefix("--").unwrap_or(dir_name);
146    let inner = after_prefix.strip_suffix("--").unwrap_or(after_prefix);
147    if inner.is_empty() {
148        return "/".to_string();
149    }
150    format!("/{}", inner.replace('-', "/"))
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::fs;
157    use tempfile::TempDir;
158
159    #[test]
160    fn test_default_sessions_dir_uses_home() {
161        let temp = TempDir::new().unwrap();
162        let resolver = PathResolver::new().with_home(temp.path());
163        assert_eq!(
164            resolver.sessions_dir(),
165            temp.path().join(".pi/agent/sessions")
166        );
167    }
168
169    #[test]
170    fn test_with_sessions_dir_override() {
171        let temp = TempDir::new().unwrap();
172        let resolver = PathResolver::new().with_sessions_dir(temp.path());
173        assert_eq!(resolver.sessions_dir(), temp.path());
174    }
175
176    #[test]
177    fn test_with_sessions_dir_override_survives_with_home() {
178        let temp = TempDir::new().unwrap();
179        let resolver = PathResolver::new()
180            .with_sessions_dir(temp.path())
181            .with_home("/some/other/home");
182        assert_eq!(resolver.sessions_dir(), temp.path());
183    }
184
185    #[test]
186    fn test_encode_roundtrip() {
187        for cwd in ["/Users/alex/proj", "/", "/a", "/a/b/c", "/home/user/repo"] {
188            let encoded = encode_project(cwd);
189            let decoded = decode_project(&encoded);
190            assert_eq!(decoded, cwd, "roundtrip failed for {cwd}");
191        }
192    }
193
194    #[test]
195    fn test_encode_strips_leading_slash() {
196        assert_eq!(
197            encode_project("/Users/alex/project"),
198            "--Users-alex-project--"
199        );
200        assert_eq!(
201            encode_project("Users/alex/project"),
202            "--Users-alex-project--"
203        );
204    }
205
206    #[test]
207    fn test_encode_wraps_double_dashes() {
208        let s = encode_project("/a/b");
209        assert!(s.starts_with("--"));
210        assert!(s.ends_with("--"));
211    }
212
213    #[test]
214    fn test_decode_root() {
215        assert_eq!(decode_project("----"), "/");
216    }
217
218    #[test]
219    fn test_encode_empty() {
220        assert_eq!(encode_project(""), "----");
221        assert_eq!(encode_project("/"), "----");
222    }
223
224    #[test]
225    fn test_project_dir_combines_sessions_and_encoded_cwd() {
226        let temp = TempDir::new().unwrap();
227        let resolver = PathResolver::new().with_sessions_dir(temp.path());
228        let pd = resolver.project_dir("/Users/alex/proj");
229        assert_eq!(pd, temp.path().join("--Users-alex-proj--"));
230    }
231
232    #[test]
233    fn test_list_projects_empty_dir() {
234        let temp = TempDir::new().unwrap();
235        let resolver = PathResolver::new().with_sessions_dir(temp.path());
236        let projects = resolver.list_projects().unwrap();
237        assert!(projects.is_empty());
238    }
239
240    #[test]
241    fn test_list_projects_nonexistent_dir() {
242        let temp = TempDir::new().unwrap();
243        let missing = temp.path().join("does-not-exist");
244        let resolver = PathResolver::new().with_sessions_dir(&missing);
245        let projects = resolver.list_projects().unwrap();
246        assert!(projects.is_empty());
247    }
248
249    #[test]
250    fn test_list_projects_skips_non_dirs() {
251        let temp = TempDir::new().unwrap();
252        fs::create_dir(temp.path().join("--Users-alex-proj--")).unwrap();
253        fs::write(temp.path().join("stray-file.txt"), "hi").unwrap();
254
255        let resolver = PathResolver::new().with_sessions_dir(temp.path());
256        let projects = resolver.list_projects().unwrap();
257        assert_eq!(projects, vec!["/Users/alex/proj".to_string()]);
258    }
259
260    #[test]
261    fn test_list_projects_returns_decoded_cwds() {
262        let temp = TempDir::new().unwrap();
263        fs::create_dir(temp.path().join("--Users-alex-proj--")).unwrap();
264        fs::create_dir(temp.path().join("--home-bob-repo--")).unwrap();
265
266        let resolver = PathResolver::new().with_sessions_dir(temp.path());
267        let projects = resolver.list_projects().unwrap();
268        assert_eq!(
269            projects,
270            vec!["/Users/alex/proj".to_string(), "/home/bob/repo".to_string(),]
271        );
272    }
273
274    #[test]
275    fn test_exists_returns_false_for_missing_dir() {
276        let temp = TempDir::new().unwrap();
277        let resolver = PathResolver::new().with_sessions_dir(temp.path().join("nope"));
278        assert!(!resolver.exists());
279    }
280
281    #[test]
282    fn test_exists_returns_true_for_created_dir() {
283        let temp = TempDir::new().unwrap();
284        let resolver = PathResolver::new().with_sessions_dir(temp.path());
285        assert!(resolver.exists());
286    }
287
288    #[test]
289    fn test_debug_impl_doesnt_panic() {
290        let resolver = PathResolver::new().with_home("/tmp/fake-home");
291        let s = format!("{resolver:?}");
292        assert!(!s.is_empty());
293    }
294
295    #[test]
296    fn test_clone_produces_equal_resolver() {
297        let resolver = PathResolver::new().with_home("/tmp/fake-home");
298        let cloned = resolver.clone();
299        assert_eq!(resolver.sessions_dir(), cloned.sessions_dir());
300    }
301
302    #[test]
303    fn test_encode_cwd_and_decode_project_dir_methods() {
304        let resolver = PathResolver::new();
305        assert_eq!(resolver.encode_cwd("/a/b"), "--a-b--");
306        assert_eq!(resolver.decode_project_dir("--a-b--"), "/a/b");
307    }
308}