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
change osyxowxttklsnrkozutkrmrsoxtzqpuy
commit 250c1e1bffcb663981536876c398fb9b53ff8937
author Alpha Chen <alpha@kejadlen.dev>
date
parent oqvlwstk
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 {