1use std::fmt;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SessionCwdIssue {
15 pub session_file: Option<PathBuf>,
17 pub session_cwd: PathBuf,
19 pub fallback_cwd: PathBuf,
21}
22
23pub fn get_missing_session_cwd_issue(
28 session_file: Option<&Path>,
29 session_cwd: &Path,
30 fallback_cwd: &Path,
31) -> Option<SessionCwdIssue> {
32 let session_file = session_file?;
33 if session_cwd.as_os_str().is_empty() {
34 return None;
35 }
36 if session_cwd.is_dir() {
37 return None;
38 }
39 Some(SessionCwdIssue {
40 session_file: Some(session_file.to_path_buf()),
41 session_cwd: session_cwd.to_path_buf(),
42 fallback_cwd: fallback_cwd.to_path_buf(),
43 })
44}
45
46pub fn resolve_session_cwd(session_cwd: &Path) -> Option<PathBuf> {
55 if session_cwd.as_os_str().is_empty() {
56 return None;
57 }
58 session_cwd.is_dir().then(|| session_cwd.to_path_buf())
59}
60
61pub fn missing_session_cwd_note(recorded: &Path, fallback: &Path) -> String {
63 format!(
64 "This session's directory ({}) no longer exists; continuing in {}.",
65 recorded.display(),
66 fallback.display()
67 )
68}
69
70pub fn adopted_session_cwd_note(launch: &Path, adopted: &Path) -> String {
77 format!(
78 "Continuing this session in {}, where it was created (launched from {}).",
79 adopted.display(),
80 launch.display()
81 )
82}
83
84pub fn untrusted_session_cwd_note(recorded: &Path) -> String {
88 format!(
89 "This session's directory ({}) is not trusted, so its project settings, \
90 skills, and context files are not loaded. Run /trust yes (or restart \
91 with --approve) to allow them.",
92 recorded.display()
93 )
94}
95
96pub fn format_missing_session_cwd_error(issue: &SessionCwdIssue) -> String {
98 let session_file = issue
99 .session_file
100 .as_ref()
101 .map(|p| format!("\nSession file: {}", p.display()))
102 .unwrap_or_default();
103 format!(
104 "Stored session working directory does not exist: {}{}\nCurrent working directory: {}",
105 issue.session_cwd.display(),
106 session_file,
107 issue.fallback_cwd.display()
108 )
109}
110
111pub fn format_missing_session_cwd_prompt(issue: &SessionCwdIssue) -> String {
114 format!(
115 "cwd from session file does not exist\n{}\n\ncontinue in current cwd\n{}",
116 issue.session_cwd.display(),
117 issue.fallback_cwd.display()
118 )
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct MissingSessionCwdError {
125 pub issue: SessionCwdIssue,
126}
127
128impl fmt::Display for MissingSessionCwdError {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 f.write_str(&format_missing_session_cwd_error(&self.issue))
131 }
132}
133
134impl std::error::Error for MissingSessionCwdError {}
135
136pub fn assert_session_cwd_exists(
138 session_file: Option<&Path>,
139 session_cwd: &Path,
140 fallback_cwd: &Path,
141) -> Result<(), MissingSessionCwdError> {
142 match get_missing_session_cwd_issue(session_file, session_cwd, fallback_cwd) {
143 Some(issue) => Err(MissingSessionCwdError { issue }),
144 None => Ok(()),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::path::PathBuf;
152
153 #[test]
154 fn no_session_file_is_ok() {
155 assert!(
156 get_missing_session_cwd_issue(None, Path::new("/whatever"), Path::new("/cwd"))
157 .is_none()
158 );
159 }
160
161 #[test]
162 fn existing_dir_is_ok() {
163 let dir = std::env::temp_dir();
164 assert!(get_missing_session_cwd_issue(Some(Path::new("/s.jsonl")), &dir, &dir).is_none());
165 }
166
167 #[test]
168 fn empty_stored_cwd_is_ok() {
169 assert!(get_missing_session_cwd_issue(
170 Some(Path::new("/s.jsonl")),
171 Path::new(""),
172 Path::new("/cwd")
173 )
174 .is_none());
175 }
176
177 #[test]
178 fn missing_dir_reports_issue() {
179 let missing = PathBuf::from("/definitely/not/here/xyzzy-1234");
180 let issue =
181 get_missing_session_cwd_issue(Some(Path::new("/s.jsonl")), &missing, Path::new("/cwd"))
182 .expect("issue");
183 assert_eq!(issue.session_cwd, missing);
184 assert!(format_missing_session_cwd_error(&issue).contains("does not exist"));
185 assert!(format_missing_session_cwd_prompt(&issue).contains("continue in current cwd"));
186 assert!(assert_session_cwd_exists(
187 Some(Path::new("/s.jsonl")),
188 &missing,
189 Path::new("/cwd")
190 )
191 .is_err());
192 }
193
194 #[test]
195 fn resolve_session_cwd_accepts_only_existing_directories() {
196 let dir = tempfile::tempdir().unwrap();
197 assert_eq!(
198 resolve_session_cwd(dir.path()),
199 Some(dir.path().to_path_buf())
200 );
201 assert_eq!(resolve_session_cwd(Path::new("")), None);
202 assert_eq!(
203 resolve_session_cwd(Path::new("/definitely/not/here/xyzzy-4321")),
204 None
205 );
206
207 let file = dir.path().join("not-a-dir");
210 std::fs::write(&file, b"x").unwrap();
211 assert_eq!(resolve_session_cwd(&file), None);
212 assert!(
213 get_missing_session_cwd_issue(Some(Path::new("/s.jsonl")), &file, dir.path()).is_some(),
214 "a file at the recorded path counts as a missing cwd"
215 );
216 }
217
218 #[test]
219 fn missing_and_untrusted_notes_name_the_directories() {
220 let note = missing_session_cwd_note(Path::new("/gone/project"), Path::new("/here"));
221 assert!(note.contains("/gone/project"), "{note}");
222 assert!(note.contains("/here"), "{note}");
223 assert!(note.contains("no longer exists"), "{note}");
224
225 let note = untrusted_session_cwd_note(Path::new("/gone/project"));
226 assert!(note.contains("/gone/project"), "{note}");
227 assert!(note.contains("/trust yes"), "{note}");
228 }
229
230 #[test]
235 fn adopting_a_directory_is_announced_with_both_paths() {
236 let note =
237 adopted_session_cwd_note(Path::new("/launch/project"), Path::new("/other/project"));
238 assert!(note.contains("/other/project"), "{note}");
239 assert!(note.contains("/launch/project"), "{note}");
240 assert!(note.contains("where it was created"), "{note}");
241 }
242}