Skip to main content

xtask_todo_lib/devshell/
session_store.rs

1//! Session persistence for devshell.
2//!
3//! - **Workspace session (preferred):** [`ENV_DEVSHELL_WORKSPACE_ROOT`] — metadata at
4//!   **`$DEVSHELL_WORKSPACE_ROOT/.cargo-devshell/session.json`** (see `docs/requirements.md` §1.1).
5//!   On Unix, [`crate::devshell::vm::export_devshell_workspace_root_env`] sets this before REPL.
6//! - **Mode S:** legacy `.dev_shell.bin` via [`crate::devshell::serialization`] when applicable.
7//! - **Mode P (guest-primary):** no legacy bin on exit; JSON holds `logical_cwd` only.
8//! - **Fallback** (no `DEVSHELL_WORKSPACE_ROOT`): **`./.cargo-devshell/session.json`** under
9//!   [`std::env::current_dir`] when it succeeds (local dev / tests).
10//! - **Legacy (migration only):** **`{stem}.session.json`** beside the bin path (e.g.
11//!   `.dev_shell.bin` → `.dev_shell.session.json`); still **read** on load, no longer preferred for new saves.
12//! - **Load order:** workspace env path → cwd workspace path → legacy beside `bin_path`.
13
14use std::io;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use serde::{Deserialize, Serialize};
19
20/// Environment variable: host directory aligned with guest workspace (Lima γ).
21pub const ENV_DEVSHELL_WORKSPACE_ROOT: &str = "DEVSHELL_WORKSPACE_ROOT";
22
23/// JSON `format` field for [`GuestPrimarySessionV1`].
24pub const FORMAT_DEVSHELL_SESSION_V1: &str = "devshell_session_v1";
25
26/// Serialize / deserialize guest-primary session file under [`ENV_DEVSHELL_WORKSPACE_ROOT`].
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct GuestPrimarySessionV1 {
29    pub format: String,
30    /// Logical cwd (absolute Unix-style path) restored on startup.
31    pub logical_cwd: String,
32    /// Wall time when saved (milliseconds since UNIX epoch).
33    pub saved_at_unix_ms: u64,
34}
35
36impl GuestPrimarySessionV1 {
37    fn new(logical_cwd: String) -> Self {
38        let saved_at_unix_ms = SystemTime::now()
39            .duration_since(UNIX_EPOCH)
40            .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(0));
41        Self {
42            format: FORMAT_DEVSHELL_SESSION_V1.to_string(),
43            logical_cwd,
44            saved_at_unix_ms,
45        }
46    }
47}
48
49/// Companion path for guest-primary metadata next to a legacy `.bin` snapshot path.
50///
51/// Example: `.dev_shell.bin` → `.dev_shell.session.json` (replaces the last extension).
52#[must_use]
53pub fn session_path_for_bin(bin_path: &Path) -> PathBuf {
54    bin_path.with_extension("session.json")
55}
56
57/// Session file under **`$DEVSHELL_WORKSPACE_ROOT/.cargo-devshell/session.json`** when the env var is set.
58#[must_use]
59pub fn workspace_session_metadata_path() -> Option<PathBuf> {
60    std::env::var(ENV_DEVSHELL_WORKSPACE_ROOT)
61        .ok()
62        .and_then(|s| {
63            let t = s.trim();
64            if t.is_empty() {
65                None
66            } else {
67                Some(
68                    PathBuf::from(t)
69                        .join(".cargo-devshell")
70                        .join("session.json"),
71                )
72            }
73        })
74}
75
76/// Same layout as [`workspace_session_metadata_path`], but rooted at [`std::env::current_dir`].
77///
78/// Used when `DEVSHELL_WORKSPACE_ROOT` is unset (e.g. plain `cargo-devshell` without VM env).
79#[must_use]
80pub fn cwd_session_metadata_path() -> Option<PathBuf> {
81    std::env::current_dir()
82        .ok()
83        .map(|p| p.join(".cargo-devshell").join("session.json"))
84}
85
86/// Resolved path to write guest-primary session JSON: workspace env → cwd `.cargo-devshell/` → legacy beside `bin_path`.
87#[must_use]
88pub fn session_metadata_path(bin_path: &Path) -> PathBuf {
89    workspace_session_metadata_path()
90        .or_else(cwd_session_metadata_path)
91        .unwrap_or_else(|| session_path_for_bin(bin_path))
92}
93
94fn load_one_guest_primary(p: &Path) -> io::Result<Option<GuestPrimarySessionV1>> {
95    if !p.is_file() {
96        return Ok(None);
97    }
98    let text = std::fs::read_to_string(p)?;
99    let v: GuestPrimarySessionV1 = serde_json::from_str(&text).map_err(|e| {
100        io::Error::new(
101            io::ErrorKind::InvalidData,
102            format!("invalid guest-primary session JSON {}: {e}", p.display()),
103        )
104    })?;
105    if v.format != FORMAT_DEVSHELL_SESSION_V1 {
106        return Ok(None);
107    }
108    Ok(Some(v))
109}
110
111/// Save guest-primary session metadata (JSON). Does **not** write `.dev_shell.bin`.
112///
113/// # Errors
114/// I/O errors from writing the JSON file or creating parent directories.
115pub fn save_guest_primary(bin_path: &Path, logical_cwd: &str) -> io::Result<()> {
116    let meta = GuestPrimarySessionV1::new(logical_cwd.to_string());
117    let text = serde_json::to_string_pretty(&meta).map_err(|e| io::Error::other(e.to_string()))?;
118    let path = session_metadata_path(bin_path);
119    if let Some(parent) = path.parent() {
120        std::fs::create_dir_all(parent)?;
121    }
122    std::fs::write(path, text)
123}
124
125/// Load guest-primary metadata: workspace env path, then cwd `.cargo-devshell/session.json`, then legacy beside `bin_path`.
126///
127/// Returns `Ok(None)` if no file exists or format is unrecognized.
128///
129/// # Errors
130/// I/O errors, or invalid JSON (wrapped as `InvalidData`).
131pub fn load_guest_primary(bin_path: &Path) -> io::Result<Option<GuestPrimarySessionV1>> {
132    if let Some(ref ws) = workspace_session_metadata_path() {
133        match load_one_guest_primary(ws) {
134            Ok(Some(m)) => return Ok(Some(m)),
135            Ok(None) => {}
136            Err(e) => return Err(e),
137        }
138    }
139    if let Some(ref cwd_meta) = cwd_session_metadata_path() {
140        match load_one_guest_primary(cwd_meta) {
141            Ok(Some(m)) => return Ok(Some(m)),
142            Ok(None) => {}
143            Err(e) => return Err(e),
144        }
145    }
146    load_one_guest_primary(&session_path_for_bin(bin_path))
147}
148
149/// On guest-primary startup: if a v1 session file exists, reset VFS to empty and restore `logical_cwd`
150/// (creating directories as needed so [`crate::devshell::vfs::Vfs::set_cwd`] succeeds).
151///
152/// If no session file exists, leaves `vfs` unchanged (e.g. legacy `.dev_shell.bin` load for cwd only).
153///
154/// # Errors
155/// I/O from [`load_guest_primary`], or `InvalidData` if `logical_cwd` cannot be created or set.
156pub fn apply_guest_primary_startup(
157    vfs: &mut crate::devshell::vfs::Vfs,
158    bin_path: &Path,
159) -> io::Result<()> {
160    let Some(meta) = load_guest_primary(bin_path)? else {
161        return Ok(());
162    };
163    *vfs = crate::devshell::vfs::Vfs::new();
164    let cwd = meta.logical_cwd.trim();
165    if cwd.is_empty() {
166        return Ok(());
167    }
168    if let Err(e) = vfs.mkdir(cwd) {
169        return Err(io::Error::new(
170            io::ErrorKind::InvalidData,
171            format!("session logical_cwd mkdir {cwd}: {e}"),
172        ));
173    }
174    vfs.set_cwd(cwd).map_err(|e| {
175        io::Error::new(
176            io::ErrorKind::InvalidData,
177            format!("session logical_cwd set_cwd {cwd}: {e}"),
178        )
179    })?;
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::test_support::{cwd_mutex, devshell_workspace_env_mutex};
187    use std::fs;
188    use std::io;
189
190    fn tmp_dir(name: &str) -> PathBuf {
191        std::env::temp_dir().join(format!(
192            "xtask_devshell_{name}_{}_{}",
193            std::process::id(),
194            std::thread::current().name().unwrap_or("test")
195        ))
196    }
197
198    struct EnvRestore {
199        old: Option<String>,
200    }
201
202    impl EnvRestore {
203        fn set_workspace_root(value: impl AsRef<std::ffi::OsStr>) -> Self {
204            let old = std::env::var(ENV_DEVSHELL_WORKSPACE_ROOT).ok();
205            std::env::set_var(ENV_DEVSHELL_WORKSPACE_ROOT, value);
206            Self { old }
207        }
208
209        fn clear_workspace_root() -> Self {
210            let old = std::env::var(ENV_DEVSHELL_WORKSPACE_ROOT).ok();
211            std::env::remove_var(ENV_DEVSHELL_WORKSPACE_ROOT);
212            Self { old }
213        }
214    }
215
216    impl Drop for EnvRestore {
217        fn drop(&mut self) {
218            match &self.old {
219                Some(s) => std::env::set_var(ENV_DEVSHELL_WORKSPACE_ROOT, s),
220                None => std::env::remove_var(ENV_DEVSHELL_WORKSPACE_ROOT),
221            }
222        }
223    }
224
225    struct CurrentDirRestore {
226        previous: PathBuf,
227    }
228
229    impl CurrentDirRestore {
230        fn chdir(dir: &Path) -> io::Result<Self> {
231            let previous = std::env::current_dir()?;
232            std::env::set_current_dir(dir)?;
233            Ok(Self { previous })
234        }
235    }
236
237    impl Drop for CurrentDirRestore {
238        fn drop(&mut self) {
239            let _ = std::env::set_current_dir(&self.previous);
240        }
241    }
242
243    #[test]
244    fn session_path_for_bin_replaces_extension() {
245        let p = Path::new(".dev_shell.bin");
246        assert_eq!(
247            session_path_for_bin(p),
248            PathBuf::from(".dev_shell.session.json")
249        );
250    }
251
252    #[test]
253    fn roundtrip_guest_primary_uses_cwd_cargo_devshell_when_no_workspace_env() {
254        let _cwd_lock = cwd_mutex();
255        let _workspace_env = devshell_workspace_env_mutex();
256        let _env = EnvRestore::clear_workspace_root();
257        let dir = tmp_dir("roundtrip");
258        let _ = fs::remove_dir_all(&dir);
259        fs::create_dir_all(&dir).unwrap();
260        let dir = dir.canonicalize().expect("canonicalize tmp");
261        let _restore_dir = CurrentDirRestore::chdir(&dir).expect("chdir tmp");
262        let bin_path = dir.join("state.bin");
263        save_guest_primary(&bin_path, "/proj/foo").unwrap();
264        let expected = dir.join(".cargo-devshell").join("session.json");
265        assert!(expected.is_file(), "expected {}", expected.display());
266        let meta = load_guest_primary(&bin_path).unwrap().expect("some");
267        assert_eq!(meta.logical_cwd, "/proj/foo");
268        assert_eq!(meta.format, FORMAT_DEVSHELL_SESSION_V1);
269        let _ = fs::remove_dir_all(&dir);
270    }
271
272    #[test]
273    fn save_prefers_workspace_env_path() {
274        let _cwd_lock = cwd_mutex();
275        let _workspace_env = devshell_workspace_env_mutex();
276        let dir = tmp_dir("ws_sess");
277        let _ = fs::remove_dir_all(&dir);
278        fs::create_dir_all(&dir).unwrap();
279        let dir = dir.canonicalize().expect("canonicalize tmp");
280        let _env = EnvRestore::set_workspace_root(&dir);
281        let bin_path = dir.join("ignored.bin");
282        save_guest_primary(&bin_path, "/x").unwrap();
283        let expected = dir.join(".cargo-devshell").join("session.json");
284        assert!(expected.is_file(), "expected {}", expected.display());
285        let loaded = load_guest_primary(&bin_path).unwrap().expect("meta");
286        assert_eq!(loaded.logical_cwd, "/x");
287        let _ = fs::remove_dir_all(&dir);
288    }
289
290    #[test]
291    fn apply_startup_sets_cwd() {
292        let _cwd_lock = cwd_mutex();
293        let _workspace_env = devshell_workspace_env_mutex();
294        let _env = EnvRestore::clear_workspace_root();
295        let dir = tmp_dir("apply");
296        let _ = fs::remove_dir_all(&dir);
297        fs::create_dir_all(&dir).unwrap();
298        let dir = dir.canonicalize().expect("canonicalize tmp");
299        let _restore_dir = CurrentDirRestore::chdir(&dir).expect("chdir tmp");
300        let bin_path = dir.join("x.bin");
301        let session_path = session_path_for_bin(&bin_path);
302        fs::write(
303            &session_path,
304            r#"{
305            "format": "devshell_session_v1",
306            "logical_cwd": "/a/b",
307            "saved_at_unix_ms": 0
308        }"#,
309        )
310        .unwrap();
311        let mut vfs = crate::devshell::vfs::Vfs::new();
312        vfs.mkdir("/a/b").unwrap();
313        vfs.write_file("/a/b/f", b"x").unwrap();
314        apply_guest_primary_startup(&mut vfs, &bin_path).unwrap();
315        assert_eq!(vfs.cwd(), "/a/b");
316        assert!(vfs.read_file("/a/b/f").is_err());
317        let _ = fs::remove_dir_all(&dir);
318    }
319}