use std::path::{Path, PathBuf};
use anyhow::{Context, bail, ensure};
use tracing::debug;
use crate::config::{MountEntry, MountMode, StateKind, expand_path};
use crate::state::StateMount;
#[derive(Debug, Clone, PartialEq)]
pub struct HostMount {
pub source: PathBuf,
pub target: PathBuf,
pub read_only: bool,
pub point_in_state: Option<PathBuf>,
pub file: PathBuf,
}
pub fn plan(
entries: &[MountEntry],
home: &Path,
state: &[StateMount],
repo_root: &Path,
) -> anyhow::Result<Vec<HostMount>> {
entries
.iter()
.filter(|entry| {
let target = expand_path(entry.target.as_deref().unwrap_or(&entry.path), home);
let on_the_repository = target == repo_root;
if on_the_repository {
debug!(
"skipping mount {}: it lands on the repository, which is mounted read-write",
target.display()
);
}
!on_the_repository
})
.map(|entry| {
let source = expand_path(&entry.path, home);
let target = entry
.target
.as_deref()
.map_or_else(|| source.clone(), |target| expand_path(target, home));
let mut point_in_state = None;
for held in state {
if held.target.starts_with(&target) {
bail!(
"mount at {} holds state path {}",
target.display(),
held.target.display()
);
}
if let Ok(below) = target.strip_prefix(&held.target) {
ensure!(
held.kind == StateKind::Dir,
"mount at {} lies inside state file {}",
target.display(),
held.target.display()
);
point_in_state = Some(held.source.join(below));
}
}
Ok(HostMount {
source,
target,
read_only: entry.mode == MountMode::Ro,
point_in_state,
file: entry.file.clone(),
})
})
.collect()
}
pub fn check_sources_exist(mounts: &[HostMount]) -> anyhow::Result<()> {
for mount in mounts {
ensure!(
mount.source.exists(),
"mount {} does not exist on the host",
mount.source.display()
);
}
Ok(())
}
pub fn create_points_in_state(mounts: &[HostMount]) -> anyhow::Result<()> {
for mount in mounts {
let Some(point) = &mount.point_in_state else {
continue;
};
if point.exists() {
continue;
}
let created = match mount.source.is_dir() {
true => std::fs::create_dir_all(point),
false => point
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(point, "")),
};
created.with_context(|| format!("creating mount point {}", point.display()))?;
}
Ok(())
}
#[cfg(test)]
#[allow(non_snake_case)] mod tests {
use super::*;
const HOME: &str = "/home/sally";
const REPO: &str = "/home/sally/repos/app";
fn entry(path: &str, target: Option<&str>, mode: MountMode) -> MountEntry {
MountEntry {
path: path.to_owned(),
target: target.map(str::to_owned),
mode,
file: PathBuf::from("/home/sally/repos/app/app.vz.yml"),
}
}
fn state_dir(target: &str) -> StateMount {
StateMount {
source: PathBuf::from("/home/sally/repos/app/.vz_state").join(&target[1..]),
target: PathBuf::from(target),
kind: StateKind::Dir,
init: None,
}
}
fn host(source: &str, target: &str, read_only: bool) -> HostMount {
HostMount {
source: PathBuf::from(source),
target: PathBuf::from(target),
read_only,
point_in_state: None,
file: PathBuf::from("/home/sally/repos/app/app.vz.yml"),
}
}
#[test]
fn plan__entries__expanded_same_path_unless_a_target() {
let entries = [
entry("~/repos", None, MountMode::Ro),
entry("/opt/tools", None, MountMode::Rw),
entry("~/skills", Some("~/.agents/skills"), MountMode::Ro),
];
let mounts = plan(&entries, Path::new(HOME), &[], Path::new(REPO)).unwrap();
let expected = vec![
host("/home/sally/repos", "/home/sally/repos", true),
host("/opt/tools", "/opt/tools", false),
host("/home/sally/skills", "/home/sally/.agents/skills", true),
];
assert_eq!(mounts, expected);
}
#[test]
fn plan__landing_on_the_repository__skipped_others_kept() {
let entries = [
entry("~/repos", None, MountMode::Ro),
entry("~/repos/app", None, MountMode::Rw),
entry("~/elsewhere", Some("~/repos/app"), MountMode::Ro),
entry("~/repos/app/sub", None, MountMode::Ro),
];
let mounts = plan(&entries, Path::new(HOME), &[], Path::new(REPO)).unwrap();
let targets: Vec<&Path> = mounts.iter().map(|mount| mount.target.as_path()).collect();
assert_eq!(
targets,
[
Path::new("/home/sally/repos"),
Path::new("/home/sally/repos/app/sub")
]
);
}
#[test]
fn plan__inside_a_state_folder__its_mount_point_in_the_state_folder() {
let state = [state_dir("/home/sally/.config/opencode")];
let entries = [entry(
"~/skills",
Some("~/.config/opencode/skills"),
MountMode::Ro,
)];
let mounts = plan(&entries, Path::new(HOME), &state, Path::new(REPO)).unwrap();
assert_eq!(
mounts[0].point_in_state,
Some(PathBuf::from(
"/home/sally/repos/app/.vz_state/home/sally/.config/opencode/skills"
))
);
}
#[test]
fn plan__holding_or_on_a_state_path_or_inside_a_state_file__is_refused() {
let file = StateMount {
kind: StateKind::File,
..state_dir("/home/sally/.gitconfig")
};
let state = [state_dir("/home/sally/.config/gh"), file];
let cases = [
(
"holding a state folder",
"~/.config",
"mount at /home/sally/.config holds state path /home/sally/.config/gh",
),
(
"on a state folder",
"~/.config/gh",
"mount at /home/sally/.config/gh holds state path /home/sally/.config/gh",
),
(
"inside a state file",
"~/.gitconfig/x",
"mount at /home/sally/.gitconfig/x lies inside state file /home/sally/.gitconfig",
),
];
for (case, target, expected) in cases {
let entries = [entry("~/x", Some(target), MountMode::Ro)];
let result = plan(&entries, Path::new(HOME), &state, Path::new(REPO));
let message = format!("{:#}", result.unwrap_err());
assert!(message.contains(expected), "{case}: {message}");
}
}
#[test]
fn plan__beside_a_state_path__is_accepted_without_a_point() {
let state = [state_dir("/home/sally/.config/gh")];
let entries = [entry("~/.config/git", None, MountMode::Ro)];
let mounts = plan(&entries, Path::new(HOME), &state, Path::new(REPO)).unwrap();
assert_eq!(mounts[0].point_in_state, None);
}
fn mounts_in_state() -> (tempfile::TempDir, [HostMount; 2]) {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("skills")).unwrap();
std::fs::write(root.path().join("AGENTS.md"), "orientation").unwrap();
let mount = |name: &str| HostMount {
source: root.path().join(name),
target: PathBuf::from("/home/sally/.config/opencode").join(name),
read_only: true,
point_in_state: Some(root.path().join(POINTS).join(name)),
file: PathBuf::from("/home/sally/repos/app/app.vz.yml"),
};
let mounts = [mount("skills"), mount("AGENTS.md")];
(root, mounts)
}
const POINTS: &str = "state/home/sally/.config/opencode";
#[test]
fn create_points_in_state__folder_source__a_folder() {
let (root, mounts) = mounts_in_state();
create_points_in_state(&mounts).unwrap();
assert!(root.path().join(POINTS).join("skills").is_dir());
}
#[test]
fn create_points_in_state__file_source__an_empty_file() {
let (root, mounts) = mounts_in_state();
create_points_in_state(&mounts).unwrap();
let point = std::fs::read_to_string(root.path().join(POINTS).join("AGENTS.md")).unwrap();
assert_eq!(point, "");
}
#[test]
fn create_points_in_state__point_exists__kept() {
let (root, mounts) = mounts_in_state();
let point = root.path().join(POINTS).join("AGENTS.md");
std::fs::create_dir_all(point.parent().unwrap()).unwrap();
std::fs::write(&point, "kept").unwrap();
create_points_in_state(&mounts).unwrap();
assert_eq!(std::fs::read_to_string(&point).unwrap(), "kept");
}
}