refactor: extract mount config into a config module
Hardcoded git/jj/ranger XDG mounts replaced by Config/Mount types
with tilde expansion via shellexpand. Both cmd_config and
generate_compose now use Config::default().resolve_mounts().
Behavior unchanged — this sets up for KDL config file loading next.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/Cargo.lock b/Cargo.lock
index 983f071..43ea787 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -176,6 +176,27 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -201,6 +222,17 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -259,6 +291,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+[[package]]
+name = "libredox"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "log"
version = "0.4.29"
@@ -319,6 +360,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "owo-colors"
version = "4.3.0"
@@ -358,11 +405,23 @@ dependencies = [
"fs-err",
"serde",
"serde_yaml",
+ "shellexpand",
"tracing",
"tracing-subscriber",
"xdg",
]
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -444,6 +503,15 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shellexpand"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
+dependencies = [
+ "dirs",
+]
+
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -467,6 +535,26 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -571,6 +659,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "windows-link"
version = "0.2.1"
diff --git a/Cargo.toml b/Cargo.toml
index 7c86d60..6965314 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ color-eyre = "*"
fs-err = "*"
serde = { version = "*", features = ["derive"] }
serde_yaml = "*"
+shellexpand = "*"
tracing = "*"
tracing-subscriber = { version = "*", features = ["env-filter"] }
xdg = "*"
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..cacf727
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,233 @@
+use std::path::PathBuf;
+
+#[derive(Debug, PartialEq)]
+pub struct Config {
+ pub mounts: Vec<Mount>,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct Mount {
+ pub source: String,
+ pub target: Option<String>,
+ pub read_only: bool,
+}
+
+/// A mount with tilde-expanded paths ready for Docker.
+#[derive(Debug, PartialEq)]
+pub struct ResolvedMount {
+ pub source: PathBuf,
+ pub target: String,
+ pub read_only: bool,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self {
+ mounts: vec![
+ Mount {
+ source: "~/.config/git".into(),
+ target: None,
+ read_only: true,
+ },
+ Mount {
+ source: "~/.config/jj".into(),
+ target: None,
+ read_only: true,
+ },
+ Mount {
+ source: "~/.local/share/ranger".into(),
+ target: None,
+ read_only: false,
+ },
+ ],
+ }
+ }
+}
+
+impl Config {
+ /// Resolve all mounts, skipping any whose source directory does not exist.
+ pub fn resolve_mounts(&self) -> Vec<ResolvedMount> {
+ self.mounts.iter().filter_map(|m| m.resolve()).collect()
+ }
+}
+
+impl Mount {
+ /// Expand tildes and derive the container target path.
+ ///
+ /// Returns `None` if the source directory does not exist on the host.
+ pub fn resolve(&self) -> Option<ResolvedMount> {
+ let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
+ if !expanded.is_dir() {
+ return None;
+ }
+
+ let target = match &self.target {
+ Some(t) => t.clone(),
+ None => tilde_to_root(&self.source),
+ };
+
+ Some(ResolvedMount {
+ source: expanded,
+ target,
+ read_only: self.read_only,
+ })
+ }
+}
+
+impl ResolvedMount {
+ /// Format as a Docker volume mount string (`source:target` or `source:target:ro`).
+ pub fn to_volume_string(&self) -> String {
+ if self.read_only {
+ format!("{}:{}:ro", self.source.display(), self.target)
+ } else {
+ format!("{}:{}", self.source.display(), self.target)
+ }
+ }
+
+ /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
+ pub fn display_target(&self) -> String {
+ if self.read_only {
+ format!("{} (ro)", self.target)
+ } else {
+ self.target.clone()
+ }
+ }
+}
+
+/// Replace a leading `~` with `/root` to derive a container path.
+fn tilde_to_root(path: &str) -> String {
+ if let Some(rest) = path.strip_prefix("~/") {
+ format!("/root/{rest}")
+ } else if path == "~" {
+ "/root".to_string()
+ } else {
+ path.to_string()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_has_three_mounts() {
+ let config = Config::default();
+ assert_eq!(config.mounts.len(), 3);
+ }
+
+ #[test]
+ fn tilde_to_root_with_subpath() {
+ assert_eq!(tilde_to_root("~/.config/git"), "/root/.config/git");
+ }
+
+ #[test]
+ fn tilde_to_root_bare() {
+ assert_eq!(tilde_to_root("~"), "/root");
+ }
+
+ #[test]
+ fn tilde_to_root_absolute_unchanged() {
+ assert_eq!(tilde_to_root("/some/path"), "/some/path");
+ }
+
+ #[test]
+ fn resolve_skips_nonexistent_source() {
+ let mount = Mount {
+ source: "/nonexistent/path/that/does/not/exist".into(),
+ target: None,
+ read_only: false,
+ };
+ assert!(mount.resolve().is_none());
+ }
+
+ #[test]
+ fn resolve_with_existing_dir_and_explicit_target() {
+ let mount = Mount {
+ source: "/tmp".into(),
+ target: Some("/container/tmp".into()),
+ read_only: true,
+ };
+ let resolved = mount.resolve().unwrap();
+ assert_eq!(resolved.source, PathBuf::from("/tmp"));
+ assert_eq!(resolved.target, "/container/tmp");
+ assert!(resolved.read_only);
+ }
+
+ #[test]
+ fn resolve_derives_target_from_source_when_no_tilde() {
+ let mount = Mount {
+ source: "/tmp".into(),
+ target: None,
+ read_only: false,
+ };
+ let resolved = mount.resolve().unwrap();
+ assert_eq!(resolved.target, "/tmp");
+ }
+
+ #[test]
+ fn volume_string_read_only() {
+ let m = ResolvedMount {
+ source: PathBuf::from("/home/user/.config/git"),
+ target: "/root/.config/git".into(),
+ read_only: true,
+ };
+ assert_eq!(
+ m.to_volume_string(),
+ "/home/user/.config/git:/root/.config/git:ro"
+ );
+ }
+
+ #[test]
+ fn volume_string_read_write() {
+ let m = ResolvedMount {
+ source: PathBuf::from("/home/user/.local/share/ranger"),
+ target: "/root/.local/share/ranger".into(),
+ read_only: false,
+ };
+ assert_eq!(
+ m.to_volume_string(),
+ "/home/user/.local/share/ranger:/root/.local/share/ranger"
+ );
+ }
+
+ #[test]
+ fn display_target_read_only() {
+ let m = ResolvedMount {
+ source: PathBuf::from("/x"),
+ target: "/root/.config/git".into(),
+ read_only: true,
+ };
+ assert_eq!(m.display_target(), "/root/.config/git (ro)");
+ }
+
+ #[test]
+ fn display_target_read_write() {
+ let m = ResolvedMount {
+ source: PathBuf::from("/x"),
+ target: "/root/.local/share/ranger".into(),
+ read_only: false,
+ };
+ assert_eq!(m.display_target(), "/root/.local/share/ranger");
+ }
+
+ #[test]
+ fn resolve_mounts_filters_nonexistent() {
+ let config = Config {
+ mounts: vec![
+ Mount {
+ source: "/tmp".into(),
+ target: Some("/container/tmp".into()),
+ read_only: false,
+ },
+ Mount {
+ source: "/nonexistent".into(),
+ target: None,
+ read_only: false,
+ },
+ ],
+ };
+ let resolved = config.resolve_mounts();
+ assert_eq!(resolved.len(), 1);
+ assert_eq!(resolved[0].target, "/container/tmp");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 2b15bbc..9d75d6f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,5 @@
+mod config;
+
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -175,35 +177,24 @@ fn cmd_config(workspace: PathBuf) -> Result<()> {
println!();
println!("Volume mounts");
- let mut mounts: Vec<(&Path, &str)> = vec![
+ let builtin_mounts: Vec<(&Path, &str)> = vec![
(&dirs.pi_data_dir, "/root/.pi"),
(&dirs.agent_dir, "/root/.pi/agent"),
(&dirs.repo_sessions_dir, "/root/.pi/agent/sessions"),
(&dirs.workspace, "/workspace"),
];
-
- let git_config = xdg::BaseDirectories::with_prefix("git").get_config_home();
- let jj_config = xdg::BaseDirectories::with_prefix("jj").get_config_home();
- let ranger_data = xdg::BaseDirectories::with_prefix("ranger").get_data_home();
-
- if let Some(ref dir) = git_config
- && dir.is_dir()
- {
- mounts.push((dir, "/root/.config/git (ro)"));
- }
- if let Some(ref dir) = jj_config
- && dir.is_dir()
- {
- mounts.push((dir, "/root/.config/jj (ro)"));
- }
- if let Some(ref dir) = ranger_data
- && dir.is_dir()
- {
- mounts.push((dir, "/root/.local/share/ranger"));
+ for (source, target) in builtin_mounts {
+ println!(" {} {} → {}", check(source), source.display(), target);
}
- for (source, target) in mounts {
- println!(" {} {} → {}", check(source), source.display(), target);
+ let user_mounts = config::Config::default().resolve_mounts();
+ for m in &user_mounts {
+ println!(
+ " {} {} → {}",
+ check(&m.source),
+ m.source.display(),
+ m.display_target()
+ );
}
println!();
@@ -385,20 +376,8 @@ fn generate_compose(
format!("{}:/workspace", workspace.display()),
];
- if let Some(dir) = xdg::BaseDirectories::with_prefix("git").get_config_home()
- && dir.is_dir()
- {
- volumes.push(format!("{}:/root/.config/git:ro", dir.display()));
- }
- if let Some(dir) = xdg::BaseDirectories::with_prefix("jj").get_config_home()
- && dir.is_dir()
- {
- volumes.push(format!("{}:/root/.config/jj:ro", dir.display()));
- }
- if let Some(dir) = xdg::BaseDirectories::with_prefix("ranger").get_data_home()
- && dir.is_dir()
- {
- volumes.push(format!("{}:/root/.local/share/ranger", dir.display()));
+ for m in config::Config::default().resolve_mounts() {
+ volumes.push(m.to_volume_string());
}
let config = ComposeConfig {