config: render mounts as a tree with glyphs
The four mount sections (Workspace, Mounts, Session mounts, Caches)
split parent/child binds across scope groups, hiding the stacking that
is the load-bearing semantic of the mount set; one tree per top-level
container path shows it, in bind order.

Assisted-by: GLM-5.3 via pi
change qxmwyumkkpumzmvxlmoovqwrpstxpzww
commit 33fa0a5ff20c69802c8fdf4a6550792a663e12dd
author Alpha Chen <alpha@kejadlen.dev>
date
parent qnzknsor
diff --git a/src/config.rs b/src/config.rs
index 5d9af4b..4831059 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -164,15 +164,6 @@ impl ResolvedMount {
     pub fn is_mask(&self) -> bool {
         self.source == Path::new(MASK_SOURCE)
     }
-
-    /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
-    pub fn display_target(&self) -> String {
-        if self.writable {
-            self.target.clone()
-        } else {
-            format!("{} (ro)", self.target)
-        }
-    }
 }
 
 /// A cache's name, checked to be a single path component.
diff --git a/src/main.rs b/src/main.rs
index 34db87e..6e6da5d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -605,11 +605,40 @@ impl Ramekin {
         by_target.into_values().collect()
     }
 
-    fn config(&self) -> Result<()> {
-        println!("Workspace");
-        println!("  {} ← {}", self.workspace_target, self.workspace.display());
+    /// Host source in display form: paths under ramekin's data or cache
+    /// directory shorten to `…`, since the sections above name those roots.
+    fn shorten_source(&self, path: &Path) -> String {
+        for home in [&self.data_home, &self.cache_dir] {
+            if let Ok(rest) = path.strip_prefix(home) {
+                return format!("…/{}", rest.display());
+            }
+        }
+        path.display().to_string()
+    }
 
-        println!();
+    /// Layer tag for the mount tree, e.g. `[binary]`, `[profile pi]`,
+    /// `[user ~/.config/ramekin]`.
+    fn layer_tag(&self, scope: config::Scope) -> String {
+        match scope {
+            config::Scope::Binary => "[binary]".to_string(),
+            config::Scope::Profile => format!("[profile {}]", self.config.profile.name),
+            scope => {
+                let path = self
+                    .config
+                    .layers
+                    .iter()
+                    .find(|l| l.scope == scope)
+                    .and_then(|l| l.path.as_ref())
+                    .map(|p| p.display().to_string());
+                match path {
+                    Some(path) => format!("[{scope} {path}]"),
+                    None => format!("[{scope}]"),
+                }
+            }
+        }
+    }
+
+    fn config(&self) -> Result<()> {
         println!("Profile");
         let selected_by = self
             .config
@@ -635,10 +664,11 @@ impl Ramekin {
             println!("  {label} {}", path.display());
         }
         println!("  cache    {}", self.cache_dir.display());
-        let outbox_dir = self.data_home.join(format!("repos/{}/outbox", self.repo_slug));
+        let outbox_dir = self
+            .data_home
+            .join(format!("repos/{}/outbox", self.repo_slug));
         println!("  outbox   {}", outbox_dir.display());
 
-        let merged_mounts = self.config.merged_mounts();
         let merged_env = self.config.merged_env();
 
         let scope_label = |scope: config::Scope| -> String {
@@ -654,62 +684,61 @@ impl Ramekin {
                 .unwrap_or_else(|| scope.to_string())
         };
 
-        // Mounts
-        if !merged_mounts.is_empty() {
-            println!();
-            println!("Mounts");
-            let scopes: BTreeSet<_> = merged_mounts.iter().map(|sv| sv.scope).collect();
-            // Masks over a directory bind a session-scoped empty dir, so show
-            // that rather than the /dev/null the config file spells.
-            let empty_placeholder = self.cache_dir.join("sessions/<session>/empty");
-            for scope in scopes {
-                println!("  {}", scope_label(scope));
-                for sv in merged_mounts.iter().filter(|sv| sv.scope == scope) {
-                    let hides_a_dir = sv.value.is_mask()
-                        && self
-                            .hidden_host_path(&sv.value.target)
-                            .is_some_and(|path| path.is_dir());
-                    let source = if hides_a_dir {
-                        &empty_placeholder
-                    } else {
-                        &sv.value.source
-                    };
-                    println!("    {} ← {}", sv.value.display_target(), source.display());
-                }
-            }
-        }
-
-        // Session mounts (sources materialize per run; shown with a placeholder)
+        // Mounts: config layers, caches, and session plumbing as one tree
+        // over the container filesystem, with later insertions winning a
+        // shared target just as they do in `final_mounts`. Sources
+        // materialize per run; the placeholders show where they land.
         let placeholder = self.cache_dir.join("sessions/<session>");
         let outbox_placeholder = outbox_dir.join("<session>");
-        println!();
-        println!("Session mounts");
+        let mut rows: BTreeMap<String, MountRow> = BTreeMap::new();
+        for sv in self.config.merged_mounts() {
+            let hides = if sv.value.is_mask() {
+                self.hidden_host_path(&sv.value.target)
+                    .map(|path| path.display().to_string())
+            } else {
+                None
+            };
+            rows.insert(
+                sv.value.target.clone(),
+                MountRow {
+                    source: self.shorten_source(&sv.value.source),
+                    writable: sv.value.writable,
+                    tag: self.layer_tag(sv.scope),
+                    hides,
+                },
+            );
+        }
         for mount in self.session_mounts(&placeholder, &outbox_placeholder) {
-            println!(
-                "    {} ← {}",
-                mount.display_target(),
-                mount.source.display()
+            rows.insert(
+                mount.target.clone(),
+                MountRow {
+                    source: self.shorten_source(&mount.source),
+                    writable: mount.writable,
+                    tag: "[session]".to_string(),
+                    hides: None,
+                },
             );
         }
-
-        // Caches
-        let caches = self.config.merged_caches()?;
-        if !caches.is_empty() {
-            println!();
-            println!("Caches");
-            let base = self.repo_caches_dir();
-            let scopes: BTreeSet<_> = caches.iter().map(|sv| sv.scope).collect();
-            for scope in scopes {
-                println!("  {}", scope_label(scope));
-                for sv in caches.iter().filter(|sv| sv.scope == scope) {
-                    println!(
-                        "    {} ← {}",
-                        sv.value.target,
-                        base.join(&sv.value.name).display()
-                    );
-                }
-            }
+        // Insertion order mirrors `final_mounts`' forced list (session
+        // mounts, then caches), so a cache retargeting a session path wins
+        // here too.
+        for sv in self.config.merged_caches()? {
+            rows.insert(
+                sv.value.target.clone(),
+                MountRow {
+                    source: self.shorten_source(&self.repo_caches_dir().join(&sv.value.name)),
+                    writable: true,
+                    tag: format!("[cache {}]", sv.scope),
+                    hides: None,
+                },
+            );
         }
+        println!();
+        println!(
+            "Mounts  {} writable · {} read-only · {} masked · … = a ramekin data/cache dir",
+            GLYPH_WRITABLE, GLYPH_READ_ONLY, GLYPH_MASK
+        );
+        print!("{}", render_mount_tree(&rows));
 
         // Environment
         if !merged_env.is_empty() {
@@ -1071,6 +1100,154 @@ fn project_image_name(repo_slug: &str) -> String {
     format!("ramekin-{repo_slug}").to_lowercase()
 }
 
+// ---------------------------------------------------------------------------
+// Mount tree for `ramekin config`
+//
+// One tree per top-level container path, siblings in bind order (parents
+// precede children). A mount's children are mounts bound on top of it, so
+// the shape of the tree shows stacking — pi's writable ephemeral agent dir
+// with read-only config and persistent state bound inside it.
+
+/// Glyphs for the mount tree. The pencil carries U+FE0F (variation
+/// selector-16) so it renders as a double-width emoji like the lock, keeping
+/// the two markers visually equal where terminals honor it.
+const GLYPH_WRITABLE: &str = "\u{270f}\u{fe0f}"; // ✏️
+const GLYPH_READ_ONLY: &str = "\u{1f512}"; // 🔒
+const GLYPH_MASK: &str = "\u{2205}"; // ∅
+
+/// One mount as a line of the tree: the source bound at the target, its
+/// writability, and the layer it came from.
+#[derive(Clone, Debug)]
+struct MountRow {
+    source: String,
+    writable: bool,
+    /// Layer tag rendered after the source, e.g. `[session]`.
+    tag: String,
+    /// Host path a mask at this target hides; `None` for real binds.
+    hides: Option<String>,
+}
+
+/// A node of the container-path tree: an optional mount plus the mounts
+/// nested under it.
+struct MountNode {
+    /// Full container path, leading components included.
+    path: String,
+    row: Option<MountRow>,
+    /// Keyed by path component, so siblings render lexicographically.
+    children: BTreeMap<String, MountNode>,
+}
+
+impl MountNode {
+    fn new(path: String) -> Self {
+        Self {
+            path,
+            row: None,
+            children: BTreeMap::new(),
+        }
+    }
+
+    fn insert(&mut self, target: &str, row: MountRow) {
+        let mut node = self;
+        let mut path = String::new();
+        for component in target.split('/').filter(|c| !c.is_empty()) {
+            path.push('/');
+            path.push_str(component);
+            node = node
+                .children
+                .entry(component.to_string())
+                .or_insert_with(|| Self::new(path.clone()));
+        }
+        node.row = Some(row);
+    }
+}
+
+/// Merge chains of non-mount nodes into their single child, so unmounted
+/// path prefixes don't add tree levels: `/root/.local/share/ranger` with
+/// nothing else under it renders as one line. Applied to every node except
+/// the implicit filesystem root, whose children become forest roots.
+fn collapse_unmounted_chains(node: &mut MountNode) {
+    for child in node.children.values_mut() {
+        collapse_unmounted_chains(child);
+    }
+    if node.row.is_none() && node.children.len() == 1 {
+        let Some((_, child)) = node.children.pop_first() else {
+            unreachable!("length checked above");
+        };
+        node.path = child.path;
+        node.row = child.row;
+        node.children = child.children;
+    }
+}
+
+/// Render the mount rows as a forest, one tree per top-level container path.
+fn render_mount_tree(rows: &BTreeMap<String, MountRow>) -> String {
+    let mut root = MountNode::new(String::new());
+    for (target, row) in rows {
+        root.insert(target, row.clone());
+    }
+    for child in root.children.values_mut() {
+        collapse_unmounted_chains(child);
+    }
+
+    // A depth-1 non-mount prefix such as `/root` would own nearly the whole
+    // tree as a bare root line, so expand it into its children instead.
+    let mut roots = Vec::new();
+    for child in std::mem::take(&mut root.children).into_values() {
+        if child.row.is_none() && child.path.matches('/').count() == 1 {
+            roots.extend(child.children.into_values());
+        } else {
+            roots.push(child);
+        }
+    }
+
+    let mut out = String::new();
+    for root in &roots {
+        out.push_str(&node_line(root, 0));
+        out.push('\n');
+        render_subtree(root, root.path.len(), "", &mut out);
+    }
+    out
+}
+
+/// A node's own line: its path relative to the forest root (`base` is the
+/// root's path length, 0 for the root itself) plus its mount, or just the
+/// path for a bare grouping node several mounts share as a prefix.
+fn node_line(node: &MountNode, base: usize) -> String {
+    // Roots (`base` 0) keep their absolute path; descendants drop the
+    // root's prefix and the `/` separator, rendering `sessions/xyz` style.
+    let path = if base == 0 {
+        node.path.as_str()
+    } else {
+        &node.path[base + 1..]
+    };
+    let Some(row) = &node.row else {
+        return path.to_string();
+    };
+    if let Some(hides) = &row.hides {
+        format!("{path} {GLYPH_MASK} hides {hides}  {}", row.tag)
+    } else {
+        let glyph = if row.writable {
+            GLYPH_WRITABLE
+        } else {
+            GLYPH_READ_ONLY
+        };
+        format!("{path} {glyph} ← {}  {}", row.source, row.tag)
+    }
+}
+
+fn render_subtree(node: &MountNode, base: usize, prefix: &str, out: &mut String) {
+    let children: Vec<_> = node.children.values().collect();
+    for (i, child) in children.iter().enumerate() {
+        let last = i + 1 == children.len();
+        out.push_str(prefix);
+        out.push_str(if last { "└── " } else { "├── " });
+        out.push_str(&node_line(child, base));
+        out.push('\n');
+        let continuation = if last { "    " } else { "│   " };
+        render_subtree(child, base, &format!("{prefix}{continuation}"), out);
+    }
+}
+
 #[derive(Serialize)]
 struct ComposeConfig {
     services: Services,
@@ -1454,4 +1631,124 @@ mod tests {
         let sessions = target("/root/.pi/agent/sessions").expect("sessions mount");
         assert_eq!(sessions.source, PathBuf::from("/data/repos/x-1/sessions"));
     }
+
+    fn row(source: &str, writable: bool, tag: &str) -> MountRow {
+        MountRow {
+            source: source.into(),
+            writable,
+            tag: tag.into(),
+            hides: None,
+        }
+    }
+
+    fn mask_row(hides: &str, tag: &str) -> MountRow {
+        MountRow {
+            source: String::new(),
+            writable: false,
+            tag: tag.into(),
+            hides: Some(hides.into()),
+        }
+    }
+
+    #[test]
+    fn mount_tree_renders_stacked_mounts_with_glyphs_and_masks() {
+        let rows = BTreeMap::from([
+            (
+                "/root/.config/git".to_string(),
+                row("/home/me/.config/git", false, "[binary]"),
+            ),
+            (
+                "/root/.config/jj".to_string(),
+                row("/home/me/.config/jj", false, "[binary]"),
+            ),
+            (
+                "/root/.pi/agent".to_string(),
+                row("…/sessions/<s>/agent", true, "[session]"),
+            ),
+            (
+                "/root/.pi/agent/AGENTS.md".to_string(),
+                row("/home/me/.config/pi/AGENTS.md", false, "[binary]"),
+            ),
+            (
+                "/root/.pi/agent/extensions".to_string(),
+                mask_row(
+                    "/home/me/.config/pi/extensions",
+                    "[project .ramekin/config.kdl]",
+                ),
+            ),
+            (
+                "/workspace/repo-abc123".to_string(),
+                row("/home/me/src/repo", true, "[session]"),
+            ),
+        ]);
+
+        let expected = "\
+/root/.config
+├── git 🔒 ← /home/me/.config/git  [binary]
+└── jj 🔒 ← /home/me/.config/jj  [binary]
+/root/.pi/agent ✏️ ← …/sessions/<s>/agent  [session]
+├── AGENTS.md 🔒 ← /home/me/.config/pi/AGENTS.md  [binary]
+└── extensions ∅ hides /home/me/.config/pi/extensions  [project .ramekin/config.kdl]
+/workspace/repo-abc123 ✏️ ← /home/me/src/repo  [session]
+";
+        assert_eq!(render_mount_tree(&rows), expected);
+    }
+
+    #[test]
+    fn mount_tree_collapses_unmounted_single_child_chains() {
+        let rows = BTreeMap::from([(
+            "/root/.local/share/ranger".to_string(),
+            row(
+                "/home/me/.local/share/ranger",
+                true,
+                "[project .ramekin/config.kdl]",
+            ),
+        )]);
+
+        let expected = "\
+/root/.local/share/ranger ✏️ ← /home/me/.local/share/ranger  [project .ramekin/config.kdl]
+";
+        assert_eq!(render_mount_tree(&rows), expected);
+    }
+
+    #[test]
+    fn mount_tree_renders_bare_root_for_a_shared_prefix() {
+        let rows = BTreeMap::from([
+            (
+                "/root/.cache/cargo".to_string(),
+                row("…/repos/r/caches/cargo", true, "[cache user]"),
+            ),
+            (
+                "/root/.cache/sccache".to_string(),
+                row("…/repos/r/caches/sccache", true, "[cache user]"),
+            ),
+        ]);
+
+        let expected = "\
+/root/.cache
+├── cargo ✏️ ← …/repos/r/caches/cargo  [cache user]
+└── sccache ✏️ ← …/repos/r/caches/sccache  [cache user]
+";
+        assert_eq!(render_mount_tree(&rows), expected);
+    }
+
+    #[test]
+    fn mount_tree_collapses_chains_below_a_mounted_parent() {
+        let rows = BTreeMap::from([
+            (
+                "/root/.pi/agent".to_string(),
+                row("…/sessions/<s>/agent", true, "[session]"),
+            ),
+            (
+                "/root/.pi/agent/sessions/xyz".to_string(),
+                row("…/repos/r/sessions", false, "[binary]"),
+            ),
+        ]);
+
+        let expected = "\
+/root/.pi/agent ✏️ ← …/sessions/<s>/agent  [session]
+└── sessions/xyz 🔒 ← …/repos/r/sessions  [binary]
+";
+        assert_eq!(render_mount_tree(&rows), expected);
+    }
 }