1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct Mount {
13 pub id: String,
15
16 pub mount_type: MountType,
18
19 pub source: String,
21
22 pub target: String,
24
25 #[serde(default)]
27 pub read_only: bool,
28
29 #[serde(default = "default_required")]
31 pub required: bool,
32
33 #[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 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 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 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 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 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 pub fn as_read_only(mut self) -> Self {
124 self.read_only = true;
125 self
126 }
127
128 pub fn as_read_write(mut self) -> Self {
130 self.read_only = false;
131 self
132 }
133
134 pub fn as_optional(mut self) -> Self {
136 self.required = false;
137 self
138 }
139
140 pub fn as_required(mut self) -> Self {
142 self.required = true;
143 self
144 }
145
146 pub fn with_description(mut self, description: impl Into<String>) -> Self {
148 self.description = Some(description.into());
149 self
150 }
151
152 pub fn expand_source(&self) -> String {
156 expand_env_vars(&self.source)
157 }
158
159 pub fn source_path(&self) -> PathBuf {
161 PathBuf::from(self.expand_source())
162 }
163
164 pub fn target_path(&self) -> PathBuf {
166 PathBuf::from(&self.target)
167 }
168
169 pub fn requires_source(&self) -> bool {
171 matches!(
172 self.mount_type,
173 MountType::File | MountType::Directory | MountType::Volume
174 )
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "snake_case", tag = "type")]
181pub enum MountType {
182 File,
184
185 Directory,
187
188 Volume,
190
191 Tmpfs {
193 size_mb: u32,
195 },
196
197 ConfigFile {
199 template: String,
201 },
202}
203
204impl MountType {
205 pub fn is_file(&self) -> bool {
207 matches!(self, MountType::File)
208 }
209
210 pub fn is_directory(&self) -> bool {
212 matches!(self, MountType::Directory)
213 }
214
215 pub fn is_volume(&self) -> bool {
217 matches!(self, MountType::Volume)
218 }
219
220 pub fn is_tmpfs(&self) -> bool {
222 matches!(self, MountType::Tmpfs { .. })
223 }
224
225 pub fn is_config_file(&self) -> bool {
227 matches!(self, MountType::ConfigFile { .. })
228 }
229
230 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
242fn expand_env_vars(input: &str) -> String {
249 let mut result = input.to_string();
250
251 let re_default = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}").unwrap();
253 result = re_default
254 .replace_all(&result, |caps: ®ex::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 let re_braced = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
263 result = re_braced
264 .replace_all(&result, |caps: ®ex::Captures| {
265 let var_name = &caps[1];
266 std::env::var(var_name).unwrap_or_default()
267 })
268 .to_string();
269
270 let re_simple = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
272 result = re_simple
273 .replace_all(&result, |caps: ®ex::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}