Skip to main content

xtask_todo_lib/devshell/vm/session_gamma/session/
mod.rs

1//! [`GammaSession`] struct and Lima `limactl` helpers.
2
3mod exec;
4
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::process::{Command, ExitStatus, Stdio};
8
9use super::super::bash_single_quoted;
10use super::super::lima_diagnostics;
11use super::super::{VmConfig, VmError, WorkspaceMode};
12use super::env::ENV_DEVSHELL_VM_GUEST_WORKSPACE;
13use super::helpers::{
14    guest_dir_for_host_path_under_workspace, guest_host_dir_link_name,
15    guest_todo_release_dir_for_cwd, resolve_limactl, workspace_parent_for_instance,
16};
17
18/// Lima-backed session: sync VFS ↔ host workspace, run tools inside the VM.
19#[derive(Debug)]
20pub struct GammaSession {
21    lima_instance: String,
22    /// Same layout as temp export: subtree leaves under this dir (see `sandbox::host_export_root`).
23    workspace_parent: PathBuf,
24    /// Guest path where `workspace_parent` is mounted.
25    guest_mount: String,
26    limactl: PathBuf,
27    vm_started: bool,
28    /// After first successful `limactl start`, run one guest/yaml diagnostic pass.
29    lima_hints_checked: bool,
30    /// After first `ensure_ready`, skip repeating guest C toolchain probe/install.
31    guest_build_essential_done: bool,
32    /// After first `ensure_ready`, skip repeating guest `todo` probe / install hints.
33    guest_todo_hint_done: bool,
34    /// When `true` (Mode S), push/pull VFS around each `cargo`/`rustup`. When `false` ([`WorkspaceMode::Guest`]), guest tree is authoritative — no sync (see guest-primary design §1c).
35    sync_vfs_with_workspace: bool,
36}
37
38impl GammaSession {
39    /// Build a γ session from VM config (does not start the VM yet).
40    ///
41    /// # Errors
42    /// Returns [`VmError::Lima`] if `limactl` cannot be resolved.
43    pub fn new(config: &VmConfig) -> Result<Self, VmError> {
44        let limactl = resolve_limactl()?;
45        let workspace_parent = workspace_parent_for_instance(&config.lima_instance);
46        let guest_mount = std::env::var(ENV_DEVSHELL_VM_GUEST_WORKSPACE)
47            .ok()
48            .map(|s| s.trim().to_string())
49            .filter(|s| !s.is_empty())
50            .unwrap_or_else(|| "/workspace".to_string());
51
52        let sync_vfs_with_workspace =
53            matches!(config.workspace_mode_effective(), WorkspaceMode::Sync);
54
55        Ok(Self {
56            lima_instance: config.lima_instance.clone(),
57            workspace_parent,
58            guest_mount,
59            limactl,
60            vm_started: false,
61            lima_hints_checked: false,
62            guest_build_essential_done: false,
63            guest_todo_hint_done: false,
64            sync_vfs_with_workspace,
65        })
66    }
67
68    /// Whether this session push/pulls the in-memory VFS around rust tools (Mode S). `false` = guest-primary ([`WorkspaceMode::Guest`]).
69    #[must_use]
70    pub fn syncs_vfs_with_host_workspace(&self) -> bool {
71        self.sync_vfs_with_workspace
72    }
73
74    fn limactl_ensure_running(&mut self) -> Result<(), VmError> {
75        if self.vm_started {
76            return Ok(());
77        }
78        std::fs::create_dir_all(&self.workspace_parent).map_err(|e| {
79            VmError::Lima(format!(
80                "create workspace dir {}: {e}",
81                self.workspace_parent.display()
82            ))
83        })?;
84
85        // `-y` / non-TUI: devshell runs `limactl` as a child without a usable TTY; Lima's TUI
86        // otherwise fails with EOF and confuses first-time create flows.
87        let st = Command::new(&self.limactl)
88            .args(["start", "-y", &self.lima_instance])
89            .stdin(Stdio::null())
90            .stdout(Stdio::inherit())
91            .stderr(Stdio::inherit())
92            .status()
93            .map_err(|e| VmError::Lima(format!("limactl start: {e}")))?;
94
95        if !st.success() {
96            lima_diagnostics::emit_start_failure_hints(&self.lima_instance);
97            return Err(VmError::Lima(format!(
98                "limactl start '{}' failed (exit code {:?}); check instance name and `limactl list`",
99                self.lima_instance,
100                st.code()
101            )));
102        }
103        self.vm_started = true;
104        Ok(())
105    }
106
107    /// Non-interactive `limactl shell -y … -- /bin/sh -c …` with captured output (VM started first).
108    fn limactl_shell_script_sh(
109        &mut self,
110        guest_workdir: &str,
111        sh_script: &str,
112    ) -> Result<std::process::Output, VmError> {
113        self.limactl_ensure_running()?;
114        Command::new(&self.limactl)
115            .arg("shell")
116            .arg("-y")
117            .arg("--workdir")
118            .arg(guest_workdir)
119            .arg(&self.lima_instance)
120            .arg("--")
121            .arg("/bin/sh")
122            .arg("-c")
123            .arg(sh_script)
124            .stdin(Stdio::null())
125            .stdout(Stdio::piped())
126            .stderr(Stdio::piped())
127            .output()
128            .map_err(|e| VmError::Lima(format!("limactl shell: {e}")))
129    }
130
131    fn limactl_shell(
132        &self,
133        guest_workdir: &str,
134        program: &str,
135        args: &[String],
136    ) -> Result<ExitStatus, VmError> {
137        let st = Command::new(&self.limactl)
138            .arg("shell")
139            .arg("--workdir")
140            .arg(guest_workdir)
141            .arg(&self.lima_instance)
142            .arg("--")
143            .arg(program)
144            .args(args)
145            .stdin(Stdio::inherit())
146            .stdout(Stdio::inherit())
147            .stderr(Stdio::inherit())
148            .status()
149            .map_err(|e| VmError::Lima(format!("limactl shell: {e}")))?;
150        Ok(st)
151    }
152
153    /// Run `limactl shell … -- program args` with captured stdout/stderr (for guest FS ops / Mode P).
154    ///
155    /// Starts the VM on first use (same as interactive `limactl shell`).
156    pub(crate) fn limactl_shell_output(
157        &mut self,
158        guest_workdir: &str,
159        program: &str,
160        args: &[String],
161    ) -> Result<std::process::Output, VmError> {
162        self.limactl_ensure_running()?;
163        Command::new(&self.limactl)
164            .arg("shell")
165            .arg("--workdir")
166            .arg(guest_workdir)
167            .arg(&self.lima_instance)
168            .arg("--")
169            .arg(program)
170            .args(args)
171            .stdin(Stdio::null())
172            .stdout(Stdio::piped())
173            .stderr(Stdio::piped())
174            .output()
175            .map_err(|e| VmError::Lima(format!("limactl shell: {e}")))
176    }
177
178    /// Pipe `stdin_data` to a guest process stdin (e.g. `dd of=…`).
179    pub(crate) fn limactl_shell_stdin(
180        &mut self,
181        guest_workdir: &str,
182        program: &str,
183        args: &[String],
184        stdin_data: &[u8],
185    ) -> Result<std::process::Output, VmError> {
186        self.limactl_ensure_running()?;
187        let mut child = Command::new(&self.limactl)
188            .arg("shell")
189            .arg("--workdir")
190            .arg(guest_workdir)
191            .arg(&self.lima_instance)
192            .arg("--")
193            .arg(program)
194            .args(args)
195            .stdin(Stdio::piped())
196            .stdout(Stdio::piped())
197            .stderr(Stdio::piped())
198            .spawn()
199            .map_err(|e| VmError::Lima(format!("limactl shell: {e}")))?;
200        if let Some(mut stdin) = child.stdin.take() {
201            stdin
202                .write_all(stdin_data)
203                .map_err(|e| VmError::Lima(format!("limactl shell: write stdin: {e}")))?;
204        }
205        child
206            .wait_with_output()
207            .map_err(|e| VmError::Lima(format!("limactl shell: {e}")))
208    }
209
210    /// Guest mount point (e.g. `/workspace`).
211    #[must_use]
212    pub fn guest_mount(&self) -> &str {
213        &self.guest_mount
214    }
215
216    fn limactl_stop(&self) -> Result<(), VmError> {
217        let st = Command::new(&self.limactl)
218            .args(["stop", &self.lima_instance])
219            .stdin(Stdio::null())
220            .stdout(Stdio::inherit())
221            .stderr(Stdio::inherit())
222            .status()
223            .map_err(|e| VmError::Lima(format!("limactl stop: {e}")))?;
224        if !st.success() {
225            return Err(VmError::Lima(format!(
226                "limactl stop '{}' failed (exit code {:?})",
227                self.lima_instance,
228                st.code()
229            )));
230        }
231        Ok(())
232    }
233
234    /// Host workspace root (for docs / debugging).
235    #[must_use]
236    pub fn workspace_parent(&self) -> &Path {
237        &self.workspace_parent
238    }
239
240    /// Path to `limactl` binary (for `exec` delegation).
241    #[must_use]
242    pub fn limactl_path(&self) -> &Path {
243        &self.limactl
244    }
245
246    /// Lima instance name (`limactl shell <this> …`).
247    #[must_use]
248    pub fn lima_instance_name(&self) -> &str {
249        &self.lima_instance
250    }
251
252    /// If host `cwd` has `target/release/todo` under [`Self::workspace_parent`], return guest path
253    /// to that `release` dir for `PATH` (same logic as `guest_todo_release_dir_for_cwd` in session helpers).
254    #[must_use]
255    pub fn guest_todo_release_path_for_shell(&self) -> Option<String> {
256        let cwd = std::env::current_dir().ok()?;
257        guest_todo_release_dir_for_cwd(&self.workspace_parent, &self.guest_mount, &cwd)
258    }
259
260    /// Guest `--workdir` and `bash -lc` body for [`super::super::SessionHolder::exec_lima_interactive_shell`]:
261    /// `cd` to the host `current_dir` project under the Lima workspace mount, optional `$HOME/host_dir` symlink,
262    /// and `~/.todo.json` → `~/host_dir/.todo.json`.
263    #[must_use]
264    pub fn lima_interactive_shell_workdir_and_inner(&self) -> (String, String) {
265        let cwd = std::env::current_dir().ok();
266        let guest_proj = cwd.as_ref().and_then(|c| {
267            guest_dir_for_host_path_under_workspace(&self.workspace_parent, &self.guest_mount, c)
268        });
269
270        if guest_proj.is_none() {
271            if let Some(ref c) = cwd {
272                let _ = writeln!(
273                    std::io::stderr(),
274                    "dev_shell: lima: host cwd {} is outside workspace_parent {} — shell starts at {}.\n\
275dev_shell: hint: `workspace_parent` defaults to the Cargo workspace root (or set DEVSHELL_VM_WORKSPACE_PARENT). \
276In ~/.lima/<instance>/lima.yaml, mount that host path, e.g.:\n  - location: \"{}\"\n    mountPoint: {}\n    writable: true\n\
277Then limactl stop/start the instance. See docs/devshell-vm-gamma.md.",
278                    c.display(),
279                    self.workspace_parent.display(),
280                    self.guest_mount,
281                    self.workspace_parent.display(),
282                    self.guest_mount
283                );
284            }
285        }
286
287        let workdir = guest_proj
288            .clone()
289            .unwrap_or_else(|| self.guest_mount.clone());
290
291        let mut inner = String::new();
292        if let Some(p) = self.guest_todo_release_path_for_shell() {
293            let _ = writeln!(
294                std::io::stderr(),
295                "dev_shell: prepending guest PATH with {p} (host todo under Lima workspace mount)"
296            );
297            inner.push_str(&format!("export PATH={}:$PATH; ", bash_single_quoted(&p)));
298        }
299
300        if let Some(ref gp) = guest_proj {
301            match guest_host_dir_link_name() {
302                Some(hd) => {
303                    inner.push_str(&format!(
304                        "export GUEST_PROJ={}; export HD={}; \
305if [ -d \"$GUEST_PROJ\" ]; then cd \"$GUEST_PROJ\" || true; \
306  ln -sfn \"$GUEST_PROJ\" \"$HOME/$HD\" 2>/dev/null || true; \
307  ln -sf \"$HOME/$HD/.todo.json\" \"$HOME/.todo.json\" 2>/dev/null || true; \
308fi; ",
309                        bash_single_quoted(gp),
310                        bash_single_quoted(&hd)
311                    ));
312                }
313                None => {
314                    inner.push_str(&format!(
315                        "export GUEST_PROJ={}; \
316if [ -d \"$GUEST_PROJ\" ]; then cd \"$GUEST_PROJ\" || true; fi; ",
317                        bash_single_quoted(gp)
318                    ));
319                }
320            }
321        }
322
323        inner.push_str("exec bash -l");
324        (workdir, inner)
325    }
326}