Skip to main content

xtask_todo_lib/devshell/workspace/
backend.rs

1//! [`WorkspaceBackend`] — Mode S ([`MemoryVfsBackend`]) vs Mode P ([`GuestPrimaryBackend`] skeleton).
2//!
3//! See `docs/superpowers/specs/2026-03-20-devshell-guest-primary-design.md` §4.
4
5use std::cell::RefCell;
6use std::process::ExitStatus;
7use std::rc::Rc;
8
9use crate::devshell::vfs::{resolve_path_with_cwd, Vfs, VfsError};
10use crate::devshell::vm::{
11    guest_path_is_under_mount, guest_project_dir_on_guest, normalize_guest_path, GuestFsError,
12    GuestFsOps, SessionHolder,
13};
14
15/// Unified error for workspace operations (until dispatch is wired).
16#[derive(Debug)]
17pub enum WorkspaceBackendError {
18    Vfs(VfsError),
19    Guest(GuestFsError),
20    Vm(crate::devshell::vm::VmError),
21    /// Logical path is not under the current logical cwd project subtree.
22    PathOutsideWorkspace,
23    /// [`MemoryVfsBackend`] does not map to guest paths.
24    ModeSOnly,
25    /// Not yet implemented (Sprint 3+).
26    Unsupported(&'static str),
27}
28
29impl std::fmt::Display for WorkspaceBackendError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Vfs(e) => write!(f, "{e}"),
33            Self::Guest(e) => write!(f, "{e}"),
34            Self::Vm(e) => write!(f, "{e}"),
35            Self::PathOutsideWorkspace => f.write_str("path outside workspace cwd"),
36            Self::ModeSOnly => f.write_str("guest path resolution not available in Mode S"),
37            Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
38        }
39    }
40}
41
42impl std::error::Error for WorkspaceBackendError {
43    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44        match self {
45            Self::Vfs(e) => Some(e),
46            Self::Guest(e) => Some(e),
47            Self::Vm(e) => Some(e),
48            _ => None,
49        }
50    }
51}
52
53impl From<VfsError> for WorkspaceBackendError {
54    fn from(e: VfsError) -> Self {
55        Self::Vfs(e)
56    }
57}
58
59impl From<GuestFsError> for WorkspaceBackendError {
60    fn from(e: GuestFsError) -> Self {
61        Self::Guest(e)
62    }
63}
64
65impl From<crate::devshell::vm::VmError> for WorkspaceBackendError {
66    fn from(e: crate::devshell::vm::VmError) -> Self {
67        Self::Vm(e)
68    }
69}
70
71/// Map a logical absolute path to a guest path (same layout as γ push: project root = `guest_project_dir_on_guest`).
72///
73/// # Errors
74/// [`WorkspaceBackendError::PathOutsideWorkspace`] if `logical_path` is not under the project root
75/// implied by `logical_cwd` (after normalization).
76pub fn logical_path_to_guest(
77    guest_mount: &str,
78    logical_cwd: &str,
79    logical_path: &str,
80) -> Result<String, WorkspaceBackendError> {
81    let abs_cwd = resolve_path_with_cwd("/", logical_cwd);
82    let abs_path = resolve_path_with_cwd(logical_cwd, logical_path);
83    let prefix = if abs_cwd.ends_with('/') {
84        abs_cwd.clone()
85    } else {
86        format!("{abs_cwd}/")
87    };
88    if abs_path != abs_cwd && !abs_path.starts_with(&prefix) {
89        return Err(WorkspaceBackendError::PathOutsideWorkspace);
90    }
91    let rel = if abs_path == abs_cwd {
92        ""
93    } else {
94        &abs_path[prefix.len()..]
95    };
96    let guest_root = guest_project_dir_on_guest(guest_mount, logical_cwd);
97    if !guest_path_is_under_mount(guest_mount, &guest_root) {
98        return Err(WorkspaceBackendError::PathOutsideWorkspace);
99    }
100    let guest_path = if rel.is_empty() {
101        guest_root
102    } else {
103        format!("{guest_root}/{rel}")
104    };
105    let guest_path =
106        normalize_guest_path(&guest_path).ok_or(WorkspaceBackendError::PathOutsideWorkspace)?;
107    if !guest_path_is_under_mount(guest_mount, &guest_path) {
108        return Err(WorkspaceBackendError::PathOutsideWorkspace);
109    }
110    Ok(guest_path)
111}
112
113/// Virtual workspace for devshell: Mode S (memory) or Mode P (guest-primary).
114pub trait WorkspaceBackend {
115    fn logical_cwd(&self) -> String;
116    /// # Errors
117    /// Returns [`WorkspaceBackendError`] when path normalization or backend cwd updates fail.
118    fn set_logical_cwd(&mut self, path: &str) -> Result<(), WorkspaceBackendError>;
119    /// # Errors
120    /// Returns [`WorkspaceBackendError`] when the backend cannot read `path`.
121    fn read_file(&mut self, path: &str) -> Result<Vec<u8>, WorkspaceBackendError>;
122    /// # Errors
123    /// Returns [`WorkspaceBackendError`] when the backend cannot write `path`.
124    fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), WorkspaceBackendError>;
125    /// # Errors
126    /// Returns [`WorkspaceBackendError`] when directory listing fails.
127    fn list_dir(&mut self, path: &str) -> Result<Vec<String>, WorkspaceBackendError>;
128    /// # Errors
129    /// Returns [`WorkspaceBackendError`] when directory creation fails.
130    fn mkdir(&mut self, path: &str) -> Result<(), WorkspaceBackendError>;
131    /// # Errors
132    /// Returns [`WorkspaceBackendError`] when remove is unsupported or fails.
133    fn remove(&mut self, path: &str) -> Result<(), WorkspaceBackendError>;
134    /// # Errors
135    /// Returns [`WorkspaceBackendError`] when existence checks fail unexpectedly.
136    fn exists(&mut self, path: &str) -> Result<bool, WorkspaceBackendError>;
137
138    /// Mode P: logical path → guest absolute path. Mode S: [`WorkspaceBackendError::ModeSOnly`].
139    ///
140    /// # Errors
141    /// Returns [`WorkspaceBackendError::ModeSOnly`] in Mode S or mapping failures in Mode P.
142    fn try_resolve_guest_path(&self, logical_path: &str) -> Result<String, WorkspaceBackendError>;
143
144    /// Run `rustup` / `cargo` (Mode S: sync VFS↔host/VM). Mode P skeleton: [`WorkspaceBackendError::Unsupported`].
145    ///
146    /// # Errors
147    /// Returns [`WorkspaceBackendError`] when tool execution/sync is unsupported or fails.
148    fn run_rust_tool(
149        &mut self,
150        vm_session: &mut SessionHolder,
151        program: &str,
152        args: &[String],
153    ) -> Result<ExitStatus, WorkspaceBackendError>;
154}
155
156/// Mode S: [`Vfs`] in memory (`Rc<RefCell<Vfs>>` matches REPL sharing).
157pub struct MemoryVfsBackend {
158    vfs: Rc<RefCell<Vfs>>,
159}
160
161impl MemoryVfsBackend {
162    #[must_use]
163    pub const fn new(vfs: Rc<RefCell<Vfs>>) -> Self {
164        Self { vfs }
165    }
166}
167
168impl WorkspaceBackend for MemoryVfsBackend {
169    fn logical_cwd(&self) -> String {
170        self.vfs.borrow().cwd().to_string()
171    }
172
173    fn set_logical_cwd(&mut self, path: &str) -> Result<(), WorkspaceBackendError> {
174        self.vfs.borrow_mut().set_cwd(path)?;
175        Ok(())
176    }
177
178    fn read_file(&mut self, path: &str) -> Result<Vec<u8>, WorkspaceBackendError> {
179        Ok(self.vfs.borrow().read_file(path)?)
180    }
181
182    fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), WorkspaceBackendError> {
183        self.vfs.borrow_mut().write_file(path, data)?;
184        Ok(())
185    }
186
187    fn list_dir(&mut self, path: &str) -> Result<Vec<String>, WorkspaceBackendError> {
188        Ok(self.vfs.borrow().list_dir(path)?)
189    }
190
191    fn mkdir(&mut self, path: &str) -> Result<(), WorkspaceBackendError> {
192        self.vfs.borrow_mut().mkdir(path)?;
193        Ok(())
194    }
195
196    fn remove(&mut self, _path: &str) -> Result<(), WorkspaceBackendError> {
197        Err(WorkspaceBackendError::Unsupported(
198            "MemoryVfsBackend::remove — add Vfs::remove or use dispatch path",
199        ))
200    }
201
202    fn exists(&mut self, path: &str) -> Result<bool, WorkspaceBackendError> {
203        let vfs = self.vfs.borrow();
204        let abs = resolve_path_with_cwd(vfs.cwd(), path);
205        Ok(vfs.resolve_absolute(&abs).is_ok())
206    }
207
208    fn try_resolve_guest_path(&self, _logical_path: &str) -> Result<String, WorkspaceBackendError> {
209        Err(WorkspaceBackendError::ModeSOnly)
210    }
211
212    fn run_rust_tool(
213        &mut self,
214        vm_session: &mut SessionHolder,
215        program: &str,
216        args: &[String],
217    ) -> Result<ExitStatus, WorkspaceBackendError> {
218        let vfs_cwd = self.vfs.borrow().cwd().to_string();
219        let mut vfs = self.vfs.borrow_mut();
220        Ok(vm_session.run_rust_tool(&mut vfs, &vfs_cwd, program, args)?)
221    }
222}
223
224/// Mode P skeleton: [`GuestFsOps`] + logical cwd + guest mount (see design §5).
225pub struct GuestPrimaryBackend {
226    ops: Box<dyn GuestFsOps>,
227    guest_mount: String,
228    logical_cwd: String,
229}
230
231impl GuestPrimaryBackend {
232    #[must_use]
233    pub fn new(guest_mount: String, logical_cwd: String, ops: Box<dyn GuestFsOps>) -> Self {
234        Self {
235            ops,
236            guest_mount,
237            logical_cwd,
238        }
239    }
240
241    #[must_use]
242    pub fn guest_mount(&self) -> &str {
243        &self.guest_mount
244    }
245}
246
247impl WorkspaceBackend for GuestPrimaryBackend {
248    fn logical_cwd(&self) -> String {
249        self.logical_cwd.clone()
250    }
251
252    fn set_logical_cwd(&mut self, path: &str) -> Result<(), WorkspaceBackendError> {
253        self.logical_cwd = resolve_path_with_cwd(&self.logical_cwd, path);
254        Ok(())
255    }
256
257    fn read_file(&mut self, path: &str) -> Result<Vec<u8>, WorkspaceBackendError> {
258        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
259        Ok(self.ops.read_file(&g)?)
260    }
261
262    fn write_file(&mut self, path: &str, data: &[u8]) -> Result<(), WorkspaceBackendError> {
263        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
264        Ok(self.ops.write_file(&g, data)?)
265    }
266
267    fn list_dir(&mut self, path: &str) -> Result<Vec<String>, WorkspaceBackendError> {
268        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
269        Ok(self.ops.list_dir(&g)?)
270    }
271
272    fn mkdir(&mut self, path: &str) -> Result<(), WorkspaceBackendError> {
273        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
274        Ok(self.ops.mkdir(&g)?)
275    }
276
277    fn remove(&mut self, path: &str) -> Result<(), WorkspaceBackendError> {
278        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
279        Ok(self.ops.remove(&g)?)
280    }
281
282    fn exists(&mut self, path: &str) -> Result<bool, WorkspaceBackendError> {
283        let g = logical_path_to_guest(&self.guest_mount, &self.logical_cwd, path)?;
284        match self.ops.read_file(&g) {
285            Ok(_) | Err(GuestFsError::IsADirectory(_)) => Ok(true),
286            Err(GuestFsError::NotFound(_)) => match self.ops.list_dir(&g) {
287                Ok(_) => Ok(true),
288                Err(GuestFsError::NotFound(_) | GuestFsError::NotADirectory(_)) => Ok(false),
289                Err(e) => Err(WorkspaceBackendError::Guest(e)),
290            },
291            Err(e) => Err(WorkspaceBackendError::Guest(e)),
292        }
293    }
294
295    fn try_resolve_guest_path(&self, logical_path: &str) -> Result<String, WorkspaceBackendError> {
296        logical_path_to_guest(&self.guest_mount, &self.logical_cwd, logical_path)
297    }
298
299    fn run_rust_tool(
300        &mut self,
301        _vm_session: &mut SessionHolder,
302        _program: &str,
303        _args: &[String],
304    ) -> Result<ExitStatus, WorkspaceBackendError> {
305        Err(WorkspaceBackendError::Unsupported(
306            "GuestPrimaryBackend::run_rust_tool (Sprint 3: push/pull skip)",
307        ))
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::devshell::vm::MockGuestFsOps;
315
316    #[test]
317    fn logical_path_to_guest_under_cwd() {
318        let g = logical_path_to_guest("/workspace", "/projects/hello", "/projects/hello/src/a.rs")
319            .unwrap();
320        assert_eq!(g, "/workspace/hello/src/a.rs");
321    }
322
323    #[test]
324    fn logical_path_to_guest_rejects_escape() {
325        assert!(logical_path_to_guest("/workspace", "/projects/hello", "/etc/passwd").is_err());
326    }
327
328    #[test]
329    fn memory_backend_roundtrip() {
330        let vfs = Rc::new(RefCell::new(Vfs::new()));
331        let mut b = MemoryVfsBackend::new(Rc::clone(&vfs));
332        b.mkdir("/a").unwrap();
333        b.write_file("/a/f", b"x").unwrap();
334        assert_eq!(b.read_file("/a/f").unwrap(), b"x");
335        assert!(b.exists("/a/f").unwrap());
336        assert!(b.try_resolve_guest_path("/a/f").is_err());
337    }
338
339    #[test]
340    fn guest_primary_backend_mock_resolves_and_writes() {
341        let mut b = GuestPrimaryBackend::new(
342            "/workspace".to_string(),
343            "/projects/foo".to_string(),
344            Box::new(MockGuestFsOps::new()),
345        );
346        b.write_file("/projects/foo/x.txt", b"hi").unwrap();
347        assert_eq!(b.read_file("/projects/foo/x.txt").unwrap(), b"hi");
348        assert_eq!(
349            b.try_resolve_guest_path("/projects/foo/x.txt").unwrap(),
350            "/workspace/foo/x.txt"
351        );
352    }
353}