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