skill_context/
mounts.rs

1//! Mount configuration types.
2//!
3//! This module defines file and directory mount specifications
4//! for execution contexts.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// File/directory mount specification.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct Mount {
13    /// Unique identifier within context.
14    pub id: String,
15
16    /// Mount type.
17    pub mount_type: MountType,
18
19    /// Host path or source (supports env var expansion like `${HOME}`).
20    pub source: String,
21
22    /// Path inside execution environment.
23    pub target: String,
24
25    /// Read-only flag.
26    #[serde(default)]
27    pub read_only: bool,
28
29    /// Required or optional.
30    #[serde(default = "default_required")]
31    pub required: bool,
32
33    /// Human-readable description.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36}
37
38fn default_required() -> bool {
39    true
40}
41
42impl Mount {
43    /// Create a new directory mount.
44    pub fn directory(
45        id: impl Into<String>,
46        source: impl Into<String>,
47        target: impl Into<String>,
48    ) -> Self {
49        Self {
50            id: id.into(),
51            mount_type: MountType::Directory,
52            source: source.into(),
53            target: target.into(),
54            read_only: false,
55            required: true,
56            description: None,
57        }
58    }
59
60    /// Create a new file mount.
61    pub fn file(
62        id: impl Into<String>,
63        source: impl Into<String>,
64        target: impl Into<String>,
65    ) -> Self {
66        Self {
67            id: id.into(),
68            mount_type: MountType::File,
69            source: source.into(),
70            target: target.into(),
71            read_only: true,
72            required: true,
73            description: None,
74        }
75    }
76
77    /// Create a named volume mount (Docker).
78    pub fn volume(id: impl Into<String>, name: impl Into<String>, target: impl Into<String>) -> Self {
79        Self {
80            id: id.into(),
81            mount_type: MountType::Volume,
82            source: name.into(),
83            target: target.into(),
84            read_only: false,
85            required: true,
86            description: None,
87        }
88    }
89
90    /// Create a tmpfs mount.
91    pub fn tmpfs(id: impl Into<String>, target: impl Into<String>, size_mb: u32) -> Self {
92        Self {
93            id: id.into(),
94            mount_type: MountType::Tmpfs { size_mb },
95            source: String::new(),
96            target: target.into(),
97            read_only: false,
98            required: true,
99            description: None,
100        }
101    }
102
103    /// Create a config file mount from a template.
104    pub fn config_file(
105        id: impl Into<String>,
106        template: impl Into<String>,
107        target: impl Into<String>,
108    ) -> Self {
109        Self {
110            id: id.into(),
111            mount_type: MountType::ConfigFile {
112                template: template.into(),
113            },
114            source: String::new(),
115            target: target.into(),
116            read_only: true,
117            required: true,
118            description: None,
119        }
120    }
121
122    /// Set the mount as read-only.
123    pub fn as_read_only(mut self) -> Self {
124        self.read_only = true;
125        self
126    }
127
128    /// Set the mount as read-write.
129    pub fn as_read_write(mut self) -> Self {
130        self.read_only = false;
131        self
132    }
133
134    /// Set the mount as optional.
135    pub fn as_optional(mut self) -> Self {
136        self.required = false;
137        self
138    }
139
140    /// Set the mount as required.
141    pub fn as_required(mut self) -> Self {
142        self.required = true;
143        self
144    }
145
146    /// Add a description.
147    pub fn with_description(mut self, description: impl Into<String>) -> Self {
148        self.description = Some(description.into());
149        self
150    }
151
152    /// Expand environment variables in the source path.
153    ///
154    /// Supports `${VAR}` and `$VAR` syntax.
155    pub fn expand_source(&self) -> String {
156        expand_env_vars(&self.source)
157    }
158
159    /// Get the source as a PathBuf with environment variables expanded.
160    pub fn source_path(&self) -> PathBuf {
161        PathBuf::from(self.expand_source())
162    }
163
164    /// Get the target as a PathBuf.
165    pub fn target_path(&self) -> PathBuf {
166        PathBuf::from(&self.target)
167    }
168
169    /// Check if this mount requires a source path to exist.
170    pub fn requires_source(&self) -> bool {
171        matches!(
172            self.mount_type,
173            MountType::File | MountType::Directory | MountType::Volume
174        )
175    }
176}
177
178/// Type of mount.
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "snake_case", tag = "type")]
181pub enum MountType {
182    /// Regular file.
183    File,
184
185    /// Directory.
186    Directory,
187
188    /// Docker volume (named volume).
189    Volume,
190
191    /// Temporary filesystem (tmpfs).
192    Tmpfs {
193        /// Size in megabytes.
194        size_mb: u32,
195    },
196
197    /// Config file generated from template.
198    ConfigFile {
199        /// Template content with variable substitution.
200        template: String,
201    },
202}
203
204impl MountType {
205    /// Check if this is a file mount.
206    pub fn is_file(&self) -> bool {
207        matches!(self, MountType::File)
208    }
209
210    /// Check if this is a directory mount.
211    pub fn is_directory(&self) -> bool {
212        matches!(self, MountType::Directory)
213    }
214
215    /// Check if this is a volume mount.
216    pub fn is_volume(&self) -> bool {
217        matches!(self, MountType::Volume)
218    }
219
220    /// Check if this is a tmpfs mount.
221    pub fn is_tmpfs(&self) -> bool {
222        matches!(self, MountType::Tmpfs { .. })
223    }
224
225    /// Check if this is a config file mount.
226    pub fn is_config_file(&self) -> bool {
227        matches!(self, MountType::ConfigFile { .. })
228    }
229
230    /// Get the display name for this mount type.
231    pub fn display_name(&self) -> &'static str {
232        match self {
233            MountType::File => "File",
234            MountType::Directory => "Directory",
235            MountType::Volume => "Volume",
236            MountType::Tmpfs { .. } => "Tmpfs",
237            MountType::ConfigFile { .. } => "Config File",
238        }
239    }
240}
241
242/// Expand environment variables in a string.
243///
244/// Supports:
245/// - `${VAR}` - Required variable
246/// - `${VAR:-default}` - Variable with default value
247/// - `$VAR` - Simple variable reference
248fn expand_env_vars(input: &str) -> String {
249    let mut result = input.to_string();
250
251    // Match ${VAR:-default} pattern
252    let re_default = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}").unwrap();
253    result = re_default
254        .replace_all(&result, |caps: &regex::Captures| {
255            let var_name = &caps[1];
256            let default = &caps[2];
257            std::env::var(var_name).unwrap_or_else(|_| default.to_string())
258        })
259        .to_string();
260
261    // Match ${VAR} pattern
262    let re_braced = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
263    result = re_braced
264        .replace_all(&result, |caps: &regex::Captures| {
265            let var_name = &caps[1];
266            std::env::var(var_name).unwrap_or_default()
267        })
268        .to_string();
269
270    // Match $VAR pattern (simple, no braces)
271    let re_simple = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
272    result = re_simple
273        .replace_all(&result, |caps: &regex::Captures| {
274            let var_name = &caps[1];
275            std::env::var(var_name).unwrap_or_default()
276        })
277        .to_string();
278
279    result
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_directory_mount() {
288        let mount = Mount::directory("data", "/host/data", "/container/data")
289            .as_read_write()
290            .with_description("Data directory");
291
292        assert_eq!(mount.id, "data");
293        assert!(mount.mount_type.is_directory());
294        assert!(!mount.read_only);
295        assert!(mount.required);
296    }
297
298    #[test]
299    fn test_file_mount() {
300        let mount = Mount::file("config", "/etc/config.json", "/app/config.json").as_read_only();
301
302        assert!(mount.mount_type.is_file());
303        assert!(mount.read_only);
304    }
305
306    #[test]
307    fn test_tmpfs_mount() {
308        let mount = Mount::tmpfs("temp", "/tmp", 100);
309
310        assert!(mount.mount_type.is_tmpfs());
311        if let MountType::Tmpfs { size_mb } = mount.mount_type {
312            assert_eq!(size_mb, 100);
313        }
314    }
315
316    #[test]
317    fn test_config_file_mount() {
318        let template = r#"
319[api]
320endpoint = "${API_ENDPOINT}"
321key = "${API_KEY}"
322"#;
323        let mount = Mount::config_file("api-config", template, "/etc/app/config.toml");
324
325        assert!(mount.mount_type.is_config_file());
326        if let MountType::ConfigFile { template: t } = &mount.mount_type {
327            assert!(t.contains("${API_ENDPOINT}"));
328        }
329    }
330
331    #[test]
332    fn test_env_var_expansion() {
333        std::env::set_var("TEST_VAR", "test_value");
334
335        assert_eq!(expand_env_vars("${TEST_VAR}"), "test_value");
336        assert_eq!(expand_env_vars("$TEST_VAR"), "test_value");
337        assert_eq!(
338            expand_env_vars("/path/${TEST_VAR}/file"),
339            "/path/test_value/file"
340        );
341
342        std::env::remove_var("TEST_VAR");
343    }
344
345    #[test]
346    fn test_env_var_default() {
347        std::env::remove_var("NONEXISTENT_VAR");
348
349        assert_eq!(
350            expand_env_vars("${NONEXISTENT_VAR:-default_value}"),
351            "default_value"
352        );
353    }
354
355    #[test]
356    fn test_mount_serialization() {
357        let mount = Mount::directory("data", "/host/data", "/container/data")
358            .as_read_only()
359            .as_optional();
360
361        let json = serde_json::to_string(&mount).unwrap();
362        let deserialized: Mount = serde_json::from_str(&json).unwrap();
363
364        assert_eq!(mount.id, deserialized.id);
365        assert_eq!(mount.read_only, deserialized.read_only);
366        assert_eq!(mount.required, deserialized.required);
367    }
368
369    #[test]
370    fn test_mount_type_display() {
371        assert_eq!(MountType::File.display_name(), "File");
372        assert_eq!(MountType::Directory.display_name(), "Directory");
373        assert_eq!(MountType::Volume.display_name(), "Volume");
374        assert_eq!(MountType::Tmpfs { size_mb: 100 }.display_name(), "Tmpfs");
375        assert_eq!(
376            MountType::ConfigFile {
377                template: String::new()
378            }
379            .display_name(),
380            "Config File"
381        );
382    }
383}