Skip to main content

rpi_cli/
session_cwd.rs

1//! Stored-session working-directory recovery.
2//!
3//! Port of the upstream `packages/coding-agent/src/core/session-cwd.ts`. A
4//! session file records the cwd it was created in; when that directory has
5//! since been deleted, restoring the session must either fall back to the
6//! current cwd or fail with a clear, actionable message instead of silently
7//! operating in a non-existent directory.
8
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// A session whose recorded working directory no longer exists on disk.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SessionCwdIssue {
15    /// The session file that recorded the missing cwd, when known.
16    pub session_file: Option<PathBuf>,
17    /// The cwd stored in the session file (does not exist).
18    pub session_cwd: PathBuf,
19    /// The cwd the process is currently running in (the fallback).
20    pub fallback_cwd: PathBuf,
21}
22
23/// Detect a missing session cwd. Returns `None` when there is no session file,
24/// the stored cwd is empty, or the stored cwd is still a directory. A **file**
25/// at the recorded path does not pass: every tool resolves relative paths
26/// against the cwd, so adopting a non-directory would break them all.
27pub 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
46/// The working directory a restored session should adopt.
47///
48/// `Some` when the session recorded a directory that still exists — restoring a
49/// conversation must return the agent to where that conversation happened,
50/// because every tool resolves relative paths against the run's cwd. `None` when
51/// the record is empty or the path is gone (or is a file, not a directory); the
52/// caller then falls back to the launch cwd and tells the user via
53/// [`missing_session_cwd_note`].
54pub 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
61/// The one-line transcript note for a session whose recorded directory is gone.
62pub 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
70/// The one-line transcript note for a session adopted from another directory.
71///
72/// Restoring a conversation moves the whole run — tools, system prompt, context
73/// files, extensions — to the recorded directory, so the user has to hear about
74/// it: a `rpi -c` launched in one project must not silently start editing files
75/// in another. Mirrors pi's `Session found in different project: <cwd>` notice.
76pub 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
84/// The one-line transcript note for a session restored into an untrusted
85/// directory. Trust is re-resolved against the adopted directory, so project
86/// settings, skills, and context files from it stay off until the user allows.
87pub 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
96/// One-line error message (native `formatMissingSessionCwdError`).
97pub 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
111/// Short prompt shown by the startup selector (native
112/// `formatMissingSessionCwdPrompt`).
113pub 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/// Error raised when a session's cwd cannot be resolved and no override was
122/// supplied.
123#[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
136/// Fail unless the session cwd exists (native `assertSessionCwdExists`).
137pub 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        // A FILE at the recorded path must not be adopted: every tool resolves
208        // relative paths against the cwd, and a file is not a directory.
209        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    /// Adopting another project's directory is a behaviour change the user must
231    /// see: the tools, context files, and extensions all switch projects. A
232    /// silent adoption would look like `rpi -c` started editing files in the
233    /// launch directory while it is actually working somewhere else.
234    #[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}