config: error on missing configured mount sources
Only merged winners are checked, so a higher layer's /dev/null mask
actually silences the error rather than just being suggested by it.

Assisted-by: GLM-5.3 via pi
change urnotxnrsqurvoqmwzvkyoquyzuvmomw
commit 755eacc74bf2485c21f46dfbf8ad9ea2271978e7
author Alpha Chen <alpha@kejadlen.dev>
date
parent krrkrtvv
diff --git a/README.md b/README.md
index 10e3901..02e76be 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@ Mount configuration merges across layers, lowest to highest precedence:
 3. **User** — every `*.kdl` in `$XDG_CONFIG_HOME/ramekin/`, merged as one layer (defining the same key twice within the layer is an error; per-file symlinking into dotfiles is a dotfiles decision)
 4. **Project** — `<workspace>/.ramekin/config.kdl`, committed
 
-Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
+Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. A configured mount whose source doesn't exist on the host is an error, not a silent skip — a mount that quietly failed to bind looks like a bug in the container. Create the path, or hide the target with a `/dev/null` mount if it isn't wanted. Only the mount that survives merging is checked, so a higher layer's override or mask also covers a lower layer's missing source.
 
 ```kdl
 mounts {
@@ -164,7 +164,7 @@ cache {
 
 Each child node names a directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/`; its argument is the container path, resolved like a mount target (`~` is the container home, a relative path resolves against the workspace mount). Omit the argument and the name serves as the path — a bare `target` is the whole declaration for a build directory the tool already looks for in the workspace. Caches merge by name across layers, so a higher layer can retarget one, and two names claiming the same container path is an error.
 
-Unlike `mounts`, the host side isn't configurable and the directory is created rather than skipped when missing — a cache that silently failed to mount would look like nothing but slow builds. Caches are per repo because build directories can't be shared: tools take an exclusive lock on them for the duration of a build, and a toolchain change invalidates their contents wholesale. Download caches have the opposite shape — keyed by name and version, safe to share — so those belong in `mounts`:
+Unlike `mounts`, the host side isn't configurable and the directory is created when missing instead of erroring — a cache that silently failed to mount would look like nothing but slow builds. Caches are per repo because build directories can't be shared: tools take an exclusive lock on them for the duration of a build, and a toolchain change invalidates their contents wholesale. Download caches have the opposite shape — keyed by name and version, safe to share — so those belong in `mounts`:
 
 ```kdl
 mounts {
diff --git a/src/config.rs b/src/config.rs
index 484744d..3f59bdb 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -224,8 +224,8 @@ impl AsRef<Path> for CacheName {
 /// `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/<name>`, created eagerly like
 /// the sessions and outbox dirs. That keeps caches per repo (build
 /// directories can't be shared: tools lock them, and a differing toolchain
-/// invalidates their contents) without a host path in config, and without
-/// the silent skip that a missing mount source gets.
+/// invalidates their contents) without a host path in config, and without a
+/// missing-source error — the directory is created before it's mounted.
 #[derive(Debug, PartialEq, Clone)]
 pub struct Cache {
     /// Directory name on the host, under this repo's `caches/`.
@@ -297,8 +297,10 @@ impl ScopedConfig {
     /// `cli_profile` is the `-p` selection, which beats any layer's.
     ///
     /// Returns an error if a config file can't be parsed, if two files
-    /// within the user layer define the same key, or if the selected
-    /// profile isn't defined anywhere.
+    /// within the user layer define the same key, if the selected profile
+    /// isn't defined anywhere, or if a configured mount's source doesn't
+    /// exist on the host (unless a higher layer overrides or masks the
+    /// target).
     pub fn load(
         workspace: &Path,
         workspace_target: &str,
@@ -369,6 +371,10 @@ impl ScopedConfig {
                 )
             })
             .collect();
+        // Where each profile was defined, for error attribution: profile
+        // mounts resolve only once one is selected, far from the file it
+        // came from.
+        let mut profile_files: BTreeMap<String, PathBuf> = BTreeMap::new();
         let mut selection = (DEFAULT_PROFILE.to_string(), Some(Scope::Binary));
         for builder in &builders {
             for profile in &builder.profiles {
@@ -380,6 +386,9 @@ impl ScopedConfig {
                     },
                 );
             }
+            for (name, file) in &builder.profile_files {
+                profile_files.insert(name.clone(), file.clone());
+            }
             if let Some(name) = &builder.selection {
                 selection = (name.clone(), Some(builder.scope));
             }
@@ -416,19 +425,21 @@ impl ScopedConfig {
             mounts: profile
                 .mounts
                 .iter()
-                .filter_map(|m| m.resolve(workspace, workspace_target))
+                .map(|m| m.resolve(workspace, workspace_target))
                 .collect(),
             env: profile.env.clone(),
             caches: Vec::new(),
         });
         layers.extend(builders.into_iter().map(LayerBuilder::build));
 
-        Ok(Self {
+        let config = Self {
             layers,
             profiles,
             selected_by,
             profile,
-        })
+        };
+        config.check_configured_mount_sources(&profile_files)?;
+        Ok(config)
     }
 
     /// Return merged mounts from all layers, de-duplicated by container target.
@@ -458,6 +469,48 @@ impl ScopedConfig {
         by_target.into_values().collect()
     }
 
+    /// Error on configured mounts (profile and file layers) whose host
+    /// source doesn't exist: a mount that silently failed to bind looks
+    /// like a bug in the container, so a missing source is config the host
+    /// can't honor. Only merged winners are checked — a higher layer's
+    /// mount at the same target, including a `/dev/null` mask, supersedes
+    /// the missing one, which makes masking the opt-out. Binary-layer
+    /// mounts are exempt: staples and agent-config entries stay
+    /// skip-if-missing.
+    fn check_configured_mount_sources(
+        &self,
+        profile_files: &BTreeMap<String, PathBuf>,
+    ) -> Result<()> {
+        for sv in self.merged_mounts() {
+            if sv.scope == Scope::Binary || sv.value.source.exists() {
+                continue;
+            }
+            let origin = match sv.scope {
+                Scope::Profile => format!(
+                    "profile `{}` (defined in {})",
+                    self.profile.name,
+                    profile_files
+                        .get(&self.profile.name)
+                        .map(|p| p.display().to_string())
+                        .unwrap_or_else(|| "an unknown file".to_string()),
+                ),
+                scope => self
+                    .layers
+                    .iter()
+                    .find(|l| l.scope == scope)
+                    .and_then(|l| l.path.as_ref())
+                    .map(|p| format!("{scope} layer ({})", p.display()))
+                    .unwrap_or_else(|| format!("{scope} layer")),
+            };
+            bail!(
+                "{origin}: mount source {} does not exist on the host; create it, or hide the \
+                 target with a /dev/null mount",
+                sv.value.source.display(),
+            );
+        }
+        Ok(())
+    }
+
     /// The host source a mask at `target` hides, when a layer declared one.
     ///
     /// Emission needs the shape of what a mask covers, which the merged
@@ -544,6 +597,9 @@ struct LayerBuilder {
     env_names: BTreeSet<String>,
     profiles: Vec<Profile>,
     profile_names: BTreeSet<String>,
+    /// File each profile was defined in, for error attribution when the
+    /// profile is selected and one of its mounts is missing on the host.
+    profile_files: BTreeMap<String, PathBuf>,
     caches: Vec<Cache>,
     cache_names: BTreeSet<CacheName>,
     selection: Option<String>,
@@ -560,6 +616,7 @@ impl LayerBuilder {
             env_names: BTreeSet::new(),
             profiles: Vec::new(),
             profile_names: BTreeSet::new(),
+            profile_files: BTreeMap::new(),
             caches: Vec::new(),
             cache_names: BTreeSet::new(),
             selection: None,
@@ -579,11 +636,9 @@ impl LayerBuilder {
         workspace_target: &str,
     ) -> Result<()> {
         for mount in &raw.mounts {
-            // Mounts with a missing host source are skipped entirely, so
-            // they don't participate in duplicate detection either.
-            let Some(resolved) = mount.resolve(workspace, workspace_target) else {
-                continue;
-            };
+            // Existence is checked after merging, so a higher layer's
+            // override or mask can still rescue a missing source.
+            let resolved = mount.resolve(workspace, workspace_target);
             ensure!(
                 self.mount_targets.insert(resolved.target.clone()),
                 "{}: mount target {} is defined twice in the {} layer",
@@ -611,6 +666,7 @@ impl LayerBuilder {
                 profile.name,
                 self.scope,
             );
+            self.profile_files.insert(profile.name.clone(), file.to_path_buf());
             self.profiles.push(profile);
         }
         for cache in raw.caches {
@@ -971,30 +1027,28 @@ impl Mount {
     /// relative target resolves against the workspace mount, so a committed
     /// project config can name a file in its own repo without a host path.
     ///
-    /// Returns `None` if the source does not exist on the host. Files and
-    /// devices (such as `/dev/null`) resolve like directories — Docker binds
-    /// them all the same way.
-    pub fn resolve(&self, workspace: &Path, workspace_target: &str) -> Option<ResolvedMount> {
+    /// Existence isn't checked here — a missing source is layer policy
+    /// (file layers error on the merged winner, the binary layer skips).
+    /// Files and devices (such as `/dev/null`) resolve like directories —
+    /// Docker binds them all the same way.
+    pub fn resolve(&self, workspace: &Path, workspace_target: &str) -> ResolvedMount {
         let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
         let expanded = if expanded.is_relative() {
             workspace.join(expanded)
         } else {
             expanded
         };
-        if !expanded.exists() {
-            return None;
-        }
 
         let target = match &self.target {
             Some(t) => resolve_container_target(t, workspace_target),
             None => resolve_container_target(&self.source, workspace_target),
         };
 
-        Some(ResolvedMount {
+        ResolvedMount {
             source: expanded,
             target,
             writable: self.writable,
-        })
+        }
     }
 }
 
@@ -1161,19 +1215,20 @@ mod tests {
     }
 
     #[test]
-    fn missing_source_skips_duplicate_detection() {
+    fn missing_source_participates_in_duplicate_detection() {
+        // A mount with a missing source still claims its target, so a second
+        // mount at the same target is a duplicate however missing the first
+        // source is.
         let mut builder = LayerBuilder::new(Scope::User, None);
         let a = parse_config(r#"mounts { "/nonexistent-a" target="/t" }"#).unwrap();
         let b = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
         builder
             .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
             .unwrap();
-        builder
+        let err = builder
             .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
-            .unwrap();
-        let layer = builder.build();
-        assert_eq!(layer.mounts.len(), 1);
-        assert_eq!(layer.mounts[0].source, PathBuf::from("/tmp"));
+            .unwrap_err();
+        assert!(err.to_string().contains("/t"), "{err}");
     }
 
     #[test]
@@ -1203,13 +1258,17 @@ mod tests {
     }
 
     #[test]
-    fn resolve_skips_nonexistent_source() {
+    fn resolve_stays_total_for_a_nonexistent_source() {
+        // Existence is layer policy, not path math: resolution returns the
+        // paths either way.
         let mount = Mount {
             source: "/nonexistent/path/that/does/not/exist".into(),
             target: None,
             writable: false,
         };
-        assert!(mount.resolve(Path::new(WS_HOST), WS).is_none());
+        let resolved = mount.resolve(Path::new(WS_HOST), WS);
+        assert_eq!(resolved.source, PathBuf::from("/nonexistent/path/that/does/not/exist"));
+        assert_eq!(resolved.target, "/nonexistent/path/that/does/not/exist");
     }
 
     #[test]
@@ -1219,7 +1278,7 @@ mod tests {
             target: Some(".envrc".into()),
             writable: false,
         };
-        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS);
         assert_eq!(resolved.source, PathBuf::from("/dev/null"));
         assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
     }
@@ -1234,7 +1293,7 @@ mod tests {
             writable: false,
         };
 
-        let resolved = mount.resolve(workspace.path(), WS).unwrap();
+        let resolved = mount.resolve(workspace.path(), WS);
 
         assert_eq!(resolved.source, workspace.path().join(".envrc"));
         assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
@@ -1247,7 +1306,7 @@ mod tests {
             target: Some("~/downloads".into()),
             writable: true,
         };
-        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS);
         assert_eq!(resolved.target, "/root/downloads");
     }
 
@@ -1258,7 +1317,7 @@ mod tests {
             target: None,
             writable: true,
         };
-        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS);
         assert_eq!(resolved.target, "/tmp");
     }
 
@@ -1594,6 +1653,145 @@ mod tests {
         assert_eq!(project.env[0].name, "FOO");
     }
 
+    #[test]
+    fn missing_project_mount_source_is_an_error() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"mounts { "/nonexistent/path/xyz" target="/root/thing" }"#,
+        )
+        .unwrap();
+
+        let err = ScopedConfig::load_from(None, dir.path(), WS, None).unwrap_err().to_string();
+        assert!(err.contains("project layer"), "{err}");
+        assert!(err.contains("config.kdl"), "{err}");
+        assert!(err.contains("/nonexistent/path/xyz"), "{err}");
+        assert!(err.contains("/dev/null"), "{err}");
+    }
+
+    #[test]
+    fn missing_user_mount_source_is_an_error() {
+        let config_home = tempfile::tempdir().unwrap();
+        fs_err::write(
+            config_home.path().join("config.kdl"),
+            r#"mounts { "/nonexistent/path/xyz" target="/root/thing" }"#,
+        )
+        .unwrap();
+        let workspace = tempfile::tempdir().unwrap();
+
+        let err = ScopedConfig::load_from(Some(config_home.path()), workspace.path(), WS, None)
+            .unwrap_err()
+            .to_string();
+        let home = config_home.path().display().to_string();
+        assert!(err.contains("user layer"), "{err}");
+        assert!(err.contains(&home), "{err}");
+        assert!(err.contains("/nonexistent/path/xyz"), "{err}");
+    }
+
+    #[test]
+    fn missing_profile_mount_source_is_an_error() {
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"
+            profile "work" {
+                agent "pi"
+                mounts { "/nonexistent/path/xyz" target="/root/thing" }
+            }
+            profile "work"
+            "#,
+        )
+        .unwrap();
+
+        let err = ScopedConfig::load_from(None, dir.path(), WS, None).unwrap_err().to_string();
+        assert!(err.contains("profile `work`"), "{err}");
+        assert!(err.contains("config.kdl"), "{err}");
+        assert!(err.contains("/nonexistent/path/xyz"), "{err}");
+    }
+
+    #[test]
+    fn a_higher_layer_mask_opts_out_of_the_missing_source_error() {
+        // The mask wins the target, so the missing source beneath it is moot.
+        let config_home = tempfile::tempdir().unwrap();
+        fs_err::write(
+            config_home.path().join("config.kdl"),
+            r#"mounts { "/nonexistent/path/xyz" target="/root/thing" }"#,
+        )
+        .unwrap();
+        let workspace = tempfile::tempdir().unwrap();
+        let ramekin_dir = workspace.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"mounts { "/dev/null" target="/root/thing" }"#,
+        )
+        .unwrap();
+
+        let config =
+            ScopedConfig::load_from(Some(config_home.path()), workspace.path(), WS, None).unwrap();
+        let merged = config.merged_mounts();
+        let winner = merged
+            .iter()
+            .find(|sv| sv.value.target == "/root/thing")
+            .unwrap();
+        assert!(winner.value.is_mask());
+        assert_eq!(winner.scope, Scope::Project);
+    }
+
+    #[test]
+    fn a_higher_layer_override_opts_out_of_the_missing_source_error() {
+        let config_home = tempfile::tempdir().unwrap();
+        fs_err::write(
+            config_home.path().join("config.kdl"),
+            r#"mounts { "/nonexistent/path/xyz" target="/root/thing" }"#,
+        )
+        .unwrap();
+        let workspace = tempfile::tempdir().unwrap();
+        let ramekin_dir = workspace.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"mounts { "/tmp" target="/root/thing" }"#,
+        )
+        .unwrap();
+
+        let config =
+            ScopedConfig::load_from(Some(config_home.path()), workspace.path(), WS, None).unwrap();
+        let merged = config.merged_mounts();
+        let winner = merged
+            .iter()
+            .find(|sv| sv.value.target == "/root/thing")
+            .unwrap();
+        assert_eq!(winner.value.source, PathBuf::from("/tmp"));
+        assert_eq!(winner.scope, Scope::Project);
+    }
+
+    #[test]
+    fn an_unselected_profile_with_a_missing_source_is_fine() {
+        // Profile mounts resolve only when selected; a profile meant for
+        // another machine doesn't constrain this one.
+        let dir = tempfile::tempdir().unwrap();
+        let ramekin_dir = dir.path().join(".ramekin");
+        fs_err::create_dir_all(&ramekin_dir).unwrap();
+        fs_err::write(
+            ramekin_dir.join("config.kdl"),
+            r#"
+            profile "work" {
+                agent "pi"
+                mounts { "/nonexistent/path/xyz" target="/root/thing" }
+            }
+            "#,
+        )
+        .unwrap();
+
+        let config = ScopedConfig::load_from(None, dir.path(), WS, None).unwrap();
+        assert_eq!(config.profile.name, "pi");
+    }
+
     #[test]
     fn user_layer_loads_from_injected_config_home() {
         // The user layer reads from the injected config home, not the