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