1use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone)]
18pub struct PathResolver {
19 home_dir: Option<PathBuf>,
20 sessions_dir_override: Option<PathBuf>,
21 sessions_dir: PathBuf,
23}
24
25impl Default for PathResolver {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl PathResolver {
32 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 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 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 pub fn sessions_dir(&self) -> &Path {
65 &self.sessions_dir
66 }
67
68 pub fn project_dir(&self, cwd: &str) -> PathBuf {
70 self.sessions_dir.join(encode_project(cwd))
71 }
72
73 pub fn exists(&self) -> bool {
75 self.sessions_dir.exists()
76 }
77
78 pub fn encode_cwd(&self, cwd: &str) -> String {
80 encode_project(cwd)
81 }
82
83 pub fn decode_project_dir(&self, dir_name: &str) -> String {
85 decode_project(dir_name)
86 }
87
88 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
121pub fn encode_project(cwd: &str) -> String {
130 let trimmed = cwd.trim_start_matches('/');
131 format!("--{}--", trimmed.replace('/', "-"))
132}
133
134pub fn decode_project(dir_name: &str) -> String {
144 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}