use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{Config, IsolationLevel};
#[derive(Debug, Clone)]
pub struct Mount {
pub host_path: PathBuf,
pub guest_path: PathBuf,
pub read_only: bool,
}
impl Mount {
pub fn rw(path: PathBuf) -> Self {
Self {
guest_path: path.clone(),
host_path: path,
read_only: false,
}
}
#[allow(dead_code)]
pub fn ro(path: PathBuf) -> Self {
Self {
guest_path: path.clone(),
host_path: path,
read_only: true,
}
}
#[allow(dead_code)]
pub fn with_guest_path(mut self, guest_path: PathBuf) -> Self {
self.guest_path = guest_path;
self
}
}
pub fn determine_project_root(worktree: &Path) -> Result<PathBuf> {
let git_common_dir = determine_git_common_dir(worktree)?;
let project_root = git_common_dir.parent().ok_or_else(|| {
anyhow::anyhow!("Git common dir has no parent: {}", git_common_dir.display())
})?;
Ok(project_root.to_path_buf())
}
pub fn determine_git_common_dir(worktree: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.arg("-C")
.arg(worktree)
.arg("rev-parse")
.arg("--path-format=absolute")
.arg("--git-common-dir")
.output()?;
if !output.status.success() {
bail!("Failed to determine git common dir");
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
Ok(PathBuf::from(path))
}
fn lima_guest_home() -> Option<PathBuf> {
let username = std::env::var("USER").ok()?;
let suffix = lima_guest_home_suffix();
Some(PathBuf::from(format!("/home/{}.{}", username, suffix)))
}
fn lima_guest_home_suffix() -> &'static str {
let version = Command::new("limactl")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
});
match version {
Some(v) => {
if let Some(ver_str) = v.trim().rsplit(' ').next()
&& lima_version_gte(ver_str, "2.1.0")
{
return "guest";
}
"linux"
}
None => "linux",
}
}
fn lima_version_gte(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.split('.')
.map(|s| s.parse::<u32>().unwrap_or(0))
.collect()
};
let va = parse(a);
let vb = parse(b);
for i in 0..va.len().max(vb.len()) {
let a_part = va.get(i).copied().unwrap_or(0);
let b_part = vb.get(i).copied().unwrap_or(0);
match a_part.cmp(&b_part) {
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Equal => continue,
}
}
true }
fn calc_worktrees_dir(project_root: &Path) -> Result<PathBuf> {
let project_name = project_root
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid project path"))?
.to_string_lossy();
let worktrees_dir = project_root
.parent()
.ok_or_else(|| anyhow::anyhow!("No parent directory"))?
.join(format!("{}__worktrees", project_name));
Ok(worktrees_dir)
}
fn expand_worktree_template(template: &str, project_root: &Path) -> Result<PathBuf> {
let project_name = project_root
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid project path"))?
.to_string_lossy();
let expanded = template.replace("{project}", &project_name);
if Path::new(&expanded).is_absolute() {
Ok(crate::util::normalize_path(Path::new(&expanded)))
} else {
Ok(crate::util::normalize_path(&project_root.join(expanded)))
}
}
fn lima_state_dir(vm_name: &str) -> Result<PathBuf> {
let state_dir = crate::xdg::state_dir()?.join("lima").join(vm_name);
std::fs::create_dir_all(&state_dir)?;
Ok(state_dir)
}
pub(crate) fn lima_state_dir_path(vm_name: &str) -> Result<PathBuf> {
Ok(crate::xdg::state_dir()?.join("lima").join(vm_name))
}
pub(crate) fn seed_claude_json(vm_name: &str) -> Result<()> {
let state_dir = lima_state_dir(vm_name)?;
let dest = state_dir.join(".claude.json");
if !dest.exists() {
std::fs::write(
&dest,
r#"{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true}"#,
)?;
}
Ok(())
}
pub fn generate_mounts(
worktree: &Path,
isolation: IsolationLevel,
config: &Config,
vm_name: &str,
agent: &str,
) -> Result<Vec<Mount>> {
let mut mounts = Vec::new();
match isolation {
IsolationLevel::Shared => {
let projects_dir = config.sandbox.lima.projects_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"Shared isolation requires 'sandbox.lima.projects_dir' in config.\n\
All projects must be under a single root directory.\n\
\n\
Example config:\n\
sandbox:\n \
lima:\n \
isolation: shared\n \
projects_dir: /Users/me/code"
)
})?;
mounts.push(Mount::rw(projects_dir.clone()));
}
IsolationLevel::Project => {
let project_root = determine_project_root(worktree)?;
mounts.push(Mount::rw(project_root.clone()));
let git_common_dir = determine_git_common_dir(worktree)?;
if !git_common_dir.starts_with(&project_root) {
mounts.push(Mount::rw(git_common_dir));
}
let worktrees_dir = calc_worktrees_dir(&project_root)?;
std::fs::create_dir_all(&worktrees_dir)?;
mounts.push(Mount::rw(worktrees_dir.clone()));
if let Some(custom_template) = config.worktree_dir.as_ref() {
let custom_dir = expand_worktree_template(custom_template, &project_root)?;
std::fs::create_dir_all(&custom_dir)?;
if custom_dir != worktrees_dir {
mounts.push(Mount::rw(custom_dir));
}
}
}
}
if let Some(auth_dir) = config.sandbox.resolved_agent_config_dir(agent) {
let guest_subpath = match agent {
"claude" => ".claude",
"gemini" => ".gemini",
"codex" => ".codex",
"opencode" => ".local/share/opencode",
_ => unreachable!(),
};
let guest_path = lima_guest_home()
.map(|h| h.join(guest_subpath))
.unwrap_or_else(|| auth_dir.clone());
mounts.push(Mount {
host_path: auth_dir,
guest_path,
read_only: false,
});
}
if agent == "opencode"
&& let Some(cfg_dir) = crate::agent_setup::opencode::opencode_config_dir()
&& cfg_dir.is_dir()
{
let guest_path = lima_guest_home()
.map(|h| h.join(".config/opencode"))
.unwrap_or_else(|| cfg_dir.clone());
mounts.push(Mount {
host_path: cfg_dir,
guest_path,
read_only: true,
});
}
if let Ok(state_dir) = lima_state_dir(vm_name) {
let guest_path = lima_guest_home()
.map(|h| h.join(".workmux-state"))
.unwrap_or_else(|| state_dir.clone());
mounts.push(Mount {
host_path: state_dir,
guest_path,
read_only: false,
});
}
for extra in config.sandbox.extra_mounts() {
let (host_path, guest_path, read_only) = extra.resolve()?;
mounts.push(Mount {
host_path,
guest_path,
read_only,
});
}
Ok(mounts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_worktree_template() {
let project_root = PathBuf::from("/Users/test/myproject");
let template = "/custom/{project}-worktrees";
let expanded = expand_worktree_template(template, &project_root).unwrap();
assert_eq!(expanded, PathBuf::from("/custom/myproject-worktrees"));
}
#[test]
fn test_expand_worktree_template_relative() {
let project_root = PathBuf::from("/Users/test/myproject");
let template = ".worktrees";
let expanded = expand_worktree_template(template, &project_root).unwrap();
assert_eq!(expanded, PathBuf::from("/Users/test/myproject/.worktrees"));
}
#[test]
fn test_seed_claude_json_writes_onboarding_config() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let dest = state_dir.join(".claude.json");
assert!(!dest.exists());
std::fs::write(
&dest,
r#"{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true}"#,
)
.unwrap();
assert!(dest.exists());
let contents: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&dest).unwrap()).unwrap();
assert_eq!(contents["hasCompletedOnboarding"], true);
}
#[test]
fn test_seed_claude_json_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let dest = state_dir.join(".claude.json");
std::fs::write(&dest, r#"{"hasCompletedOnboarding":true,"tips_shown":10}"#).unwrap();
if !dest.exists() {
std::fs::write(
&dest,
r#"{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true}"#,
)
.unwrap();
}
let contents: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&dest).unwrap()).unwrap();
assert_eq!(contents["tips_shown"], 10);
}
#[test]
fn test_lima_state_dir_path_format() {
let path = lima_state_dir_path("wm-myproject-abc12345").unwrap();
assert!(path.ends_with("workmux/lima/wm-myproject-abc12345"));
}
#[test]
fn test_lima_version_gte() {
assert!(lima_version_gte("2.1.0", "2.1.0"));
assert!(lima_version_gte("2.1.1", "2.1.0"));
assert!(lima_version_gte("2.2.0", "2.1.0"));
assert!(lima_version_gte("3.0.0", "2.1.0"));
assert!(!lima_version_gte("2.0.3", "2.1.0"));
assert!(!lima_version_gte("1.9.9", "2.1.0"));
assert!(!lima_version_gte("2.0.99", "2.1.0"));
}
#[test]
fn test_lima_guest_home_suffix_returns_valid_suffix() {
let suffix = lima_guest_home_suffix();
assert!(
suffix == "linux" || suffix == "guest",
"unexpected suffix: {}",
suffix
);
}
}