Introduce scoped config with ConfigSource
Replace the flat config_mounts Vec in Ramekin with a ResolvedConfig
that pairs resolved mounts with their ConfigSource (Default or User).

Config::load() now returns a ResolvedConfig directly, resolving mounts
internally. The config command displays the source in the section
header, e.g. 'Config volume mounts (default)' or
'Config volume mounts (~/.config/ramekin/config.kdl)'.

This sets up the config system for adding project-level configs later.
change vsxrqqvsturvqqrrtzyvvorlkznsknvx
commit e297cb93f23beb17d73989c877c4722d3adbb516
author Alpha Chen <alpha@kejadlen.dev>
date
parent qvxxmvkl
diff --git a/src/config.rs b/src/config.rs
index 78be814..b3a2052 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,3 +1,4 @@
+use std::fmt;
 use std::path::PathBuf;
 
 use color_eyre::eyre::{Context, Result};
@@ -19,6 +20,31 @@ pub struct Mount {
     pub writable: bool,
 }
 
+/// Where a configuration was loaded from.
+#[derive(Debug, PartialEq)]
+pub enum ConfigSource {
+    /// Hardcoded defaults (no config file found).
+    Default,
+    /// User-level config file at the given path.
+    User(PathBuf),
+}
+
+impl fmt::Display for ConfigSource {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Default => write!(f, "default"),
+            Self::User(path) => write!(f, "{}", path.display()),
+        }
+    }
+}
+
+/// A loaded configuration together with its source.
+#[derive(Debug)]
+pub struct ResolvedConfig {
+    pub source: ConfigSource,
+    pub mounts: Vec<ResolvedMount>,
+}
+
 /// A mount with tilde-expanded paths ready for Docker.
 #[derive(Debug, PartialEq)]
 pub struct ResolvedMount {
@@ -34,24 +60,35 @@ impl Default for Config {
 }
 
 impl Config {
-    /// Load configuration from `~/.config/ramekin/config.kdl`.
+    /// Load configuration and resolve mounts.
     ///
-    /// Falls back to hardcoded defaults when the file doesn't exist.
-    /// Returns an error if the file exists but can't be parsed.
-    pub fn load() -> Result<Self> {
+    /// Tries `~/.config/ramekin/config.kdl` first; falls back to hardcoded
+    /// defaults when the file doesn't exist. Returns an error if the file
+    /// exists but can't be parsed.
+    pub fn load() -> Result<ResolvedConfig> {
         let xdg = xdg::BaseDirectories::with_prefix("ramekin");
         let config_path = xdg
             .place_config_file("config.kdl")
             .wrap_err("failed to determine config file path")?;
 
-        if !config_path.exists() {
-            return Ok(Self::fallback());
+        if config_path.exists() {
+            let content =
+                fs_err::read_to_string(&config_path).wrap_err("failed to read config file")?;
+            let config: Config =
+                serde_kdl2::from_str(&content).wrap_err("failed to parse config file")?;
+            let mounts = config.resolve_mounts();
+            Ok(ResolvedConfig {
+                source: ConfigSource::User(config_path),
+                mounts,
+            })
+        } else {
+            let config = Self::fallback();
+            let mounts = config.resolve_mounts();
+            Ok(ResolvedConfig {
+                source: ConfigSource::Default,
+                mounts,
+            })
         }
-
-        let content =
-            fs_err::read_to_string(&config_path).wrap_err("failed to read config file")?;
-
-        serde_kdl2::from_str(&content).wrap_err("failed to parse config file")
     }
 
     /// Fallback configuration with hardcoded mounts.
@@ -76,11 +113,9 @@ impl Config {
             ],
         }
     }
-}
 
-impl Config {
     /// Resolve all mounts, skipping any whose source directory does not exist.
-    pub fn resolve_mounts(&self) -> Vec<ResolvedMount> {
+    fn resolve_mounts(&self) -> Vec<ResolvedMount> {
         self.mounts.iter().filter_map(|m| m.resolve()).collect()
     }
 }
@@ -318,4 +353,16 @@ mod tests {
         assert_eq!(resolved.len(), 1);
         assert_eq!(resolved[0].target, "/container/tmp");
     }
+
+    #[test]
+    fn config_source_display_default() {
+        let source = ConfigSource::Default;
+        assert_eq!(source.to_string(), "default");
+    }
+
+    #[test]
+    fn config_source_display_user() {
+        let source = ConfigSource::User(PathBuf::from("/home/user/.config/ramekin/config.kdl"));
+        assert_eq!(source.to_string(), "/home/user/.config/ramekin/config.kdl");
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index 8d182f9..c45ada5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -68,7 +68,7 @@ struct Ramekin {
     cache_dir: PathBuf,
     custom_dockerfile: Option<PathBuf>,
     builtin_mounts: Vec<config::ResolvedMount>,
-    config_mounts: Vec<config::ResolvedMount>,
+    config: config::ResolvedConfig,
 }
 
 impl Ramekin {
@@ -141,7 +141,6 @@ impl Ramekin {
 
         // Config mounts (user-configurable, skipped when source doesn't exist)
         let config = config::Config::load().wrap_err("failed to load ramekin configuration")?;
-        let config_mounts = config.resolve_mounts();
 
         Ok(Self {
             workspace,
@@ -151,7 +150,7 @@ impl Ramekin {
             cache_dir,
             custom_dockerfile,
             builtin_mounts,
-            config_mounts,
+            config,
         })
     }
 
@@ -196,11 +195,11 @@ impl Ramekin {
         }
 
         println!();
-        println!("Config volume mounts");
-        if self.config_mounts.is_empty() {
+        println!("Config volume mounts ({})", self.config.source);
+        if self.config.mounts.is_empty() {
             println!("  (none)");
         } else {
-            for m in &self.config_mounts {
+            for m in &self.config.mounts {
                 println!(
                     "  {} {} → {}",
                     check(&m.source),
@@ -272,7 +271,7 @@ impl Ramekin {
         let all_mounts: Vec<_> = self
             .builtin_mounts
             .iter()
-            .chain(&self.config_mounts)
+            .chain(&self.config.mounts)
             .collect();
         let compose = generate_compose(&dockerfile, &build_context, &all_mounts);
         let compose_file = session_dir.join("compose.yml");