shimexe_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use crate::error::{Result, ShimError};
6use crate::template::ArgsConfig;
7use crate::utils::expand_env_vars;
8
9/// Main shim configuration structure
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ShimConfig {
12    /// Core shim configuration
13    pub shim: ShimCore,
14    /// Advanced argument configuration
15    #[serde(default)]
16    pub args: ArgsConfig,
17    /// Environment variables to set
18    #[serde(default)]
19    pub env: HashMap<String, String>,
20    /// Optional metadata
21    #[serde(default)]
22    pub metadata: ShimMetadata,
23    /// Auto-update configuration
24    #[serde(default)]
25    pub auto_update: Option<AutoUpdate>,
26}
27
28/// Core shim configuration
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ShimCore {
31    /// Name of the shim
32    pub name: String,
33    /// Path to the target executable
34    pub path: String,
35    /// Default arguments to pass to the executable
36    #[serde(default)]
37    pub args: Vec<String>,
38    /// Working directory for the executable
39    #[serde(default)]
40    pub cwd: Option<String>,
41}
42
43/// Optional metadata for the shim
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ShimMetadata {
46    /// Description of the shim
47    pub description: Option<String>,
48    /// Version of the shim configuration
49    pub version: Option<String>,
50    /// Author of the shim
51    pub author: Option<String>,
52    /// Tags for categorization
53    #[serde(default)]
54    pub tags: Vec<String>,
55}
56
57/// Auto-update configuration for the shim
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AutoUpdate {
60    /// Enable auto-update
61    #[serde(default)]
62    pub enabled: bool,
63    /// Update provider type
64    pub provider: UpdateProvider,
65    /// Download URL template with version placeholder
66    pub download_url: String,
67    /// Version check URL or pattern
68    pub version_check: VersionCheck,
69    /// Update frequency in hours (0 = check every run)
70    #[serde(default)]
71    pub check_interval_hours: u64,
72    /// Pre-update command to run
73    pub pre_update_command: Option<String>,
74    /// Post-update command to run
75    pub post_update_command: Option<String>,
76}
77
78/// Update provider types
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum UpdateProvider {
82    /// GitHub releases
83    Github {
84        /// Repository owner/name
85        repo: String,
86        /// Asset name pattern (supports {version}, {os}, {arch} placeholders)
87        asset_pattern: String,
88        /// Include pre-releases
89        #[serde(default)]
90        include_prerelease: bool,
91    },
92    /// Direct HTTPS download
93    Https {
94        /// Base URL for downloads
95        base_url: String,
96        /// URL pattern for version checking
97        version_url: Option<String>,
98    },
99    /// Custom provider
100    Custom {
101        /// Custom update script or command
102        update_command: String,
103        /// Custom version check command
104        version_command: String,
105    },
106}
107
108/// Version check configuration
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum VersionCheck {
112    /// GitHub API for latest release
113    GithubLatest {
114        /// Repository owner/name
115        repo: String,
116        /// Include pre-releases
117        #[serde(default)]
118        include_prerelease: bool,
119    },
120    /// HTTP endpoint returning version
121    Http {
122        /// URL to check for version
123        url: String,
124        /// JSON path to extract version (e.g., "$.version")
125        json_path: Option<String>,
126        /// Regex pattern to extract version
127        regex_pattern: Option<String>,
128    },
129    /// Semantic version comparison
130    Semver {
131        /// Current version
132        current: String,
133        /// Version check URL
134        check_url: String,
135    },
136    /// Custom version check command
137    Command {
138        /// Command to run for version check
139        command: String,
140        /// Arguments for the command
141        args: Vec<String>,
142    },
143}
144
145impl ShimConfig {
146    /// Load shim configuration from a TOML file
147    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
148        let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
149
150        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
151
152        config.validate()?;
153        Ok(config)
154    }
155
156    /// Save shim configuration to a TOML file
157    pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
158        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
159
160        std::fs::write(path, content).map_err(ShimError::Io)?;
161
162        Ok(())
163    }
164
165    /// Validate the configuration
166    pub fn validate(&self) -> Result<()> {
167        if self.shim.name.is_empty() {
168            return Err(ShimError::Config("Shim name cannot be empty".to_string()));
169        }
170
171        if self.shim.path.is_empty() {
172            return Err(ShimError::Config("Shim path cannot be empty".to_string()));
173        }
174
175        Ok(())
176    }
177
178    /// Expand environment variables in the configuration
179    pub fn expand_env_vars(&mut self) -> Result<()> {
180        // Expand path
181        self.shim.path = expand_env_vars(&self.shim.path)?;
182
183        // Expand args
184        for arg in &mut self.shim.args {
185            *arg = expand_env_vars(arg)?;
186        }
187
188        // Expand cwd if present
189        if let Some(ref mut cwd) = self.shim.cwd {
190            *cwd = expand_env_vars(cwd)?;
191        }
192
193        // Expand environment variables
194        for value in self.env.values_mut() {
195            *value = expand_env_vars(value)?;
196        }
197
198        Ok(())
199    }
200
201    /// Get the resolved executable path
202    pub fn get_executable_path(&self) -> Result<PathBuf> {
203        let expanded_path = expand_env_vars(&self.shim.path)?;
204        let path = PathBuf::from(expanded_path);
205
206        if path.is_absolute() {
207            Ok(path)
208        } else {
209            // Try to find in PATH
210            which::which(&path).map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::io::Write;
219    use tempfile::NamedTempFile;
220
221    #[test]
222    fn test_shim_config_from_file() {
223        let mut temp_file = NamedTempFile::new().unwrap();
224        writeln!(
225            temp_file,
226            r#"
227[shim]
228name = "test"
229path = "echo"
230args = ["hello"]
231
232[env]
233TEST_VAR = "test_value"
234
235[metadata]
236description = "Test shim"
237version = "1.0.0"
238        "#
239        )
240        .unwrap();
241
242        let config = ShimConfig::from_file(temp_file.path()).unwrap();
243        assert_eq!(config.shim.name, "test");
244        assert_eq!(config.shim.path, "echo");
245        assert_eq!(config.shim.args, vec!["hello"]);
246        assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
247        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
248    }
249
250    #[test]
251    fn test_shim_config_basic_structure() {
252        let mut temp_file = NamedTempFile::new().unwrap();
253        writeln!(
254            temp_file,
255            r#"
256[shim]
257name = "test"
258path = "echo"
259
260[args]
261mode = "template"
262
263[metadata]
264description = "Test shim"
265        "#
266        )
267        .unwrap();
268
269        let config = ShimConfig::from_file(temp_file.path()).unwrap();
270        assert_eq!(config.shim.name, "test");
271        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
272        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
273    }
274
275    #[test]
276    fn test_shim_config_validation() {
277        // Valid config
278        let config = ShimConfig {
279            shim: ShimCore {
280                name: "test".to_string(),
281                path: "echo".to_string(),
282                args: vec![],
283                cwd: None,
284            },
285            args: Default::default(),
286            env: HashMap::new(),
287            metadata: Default::default(),
288            auto_update: None,
289        };
290        assert!(config.validate().is_ok());
291
292        // Invalid config - empty name
293        let invalid_config = ShimConfig {
294            shim: ShimCore {
295                name: "".to_string(),
296                path: "echo".to_string(),
297                args: vec![],
298                cwd: None,
299            },
300            args: Default::default(),
301            env: HashMap::new(),
302            metadata: Default::default(),
303            auto_update: None,
304        };
305        assert!(invalid_config.validate().is_err());
306
307        // Invalid config - empty path
308        let invalid_config = ShimConfig {
309            shim: ShimCore {
310                name: "test".to_string(),
311                path: "".to_string(),
312                args: vec![],
313                cwd: None,
314            },
315            args: Default::default(),
316            env: HashMap::new(),
317            metadata: Default::default(),
318            auto_update: None,
319        };
320        assert!(invalid_config.validate().is_err());
321    }
322
323    #[test]
324    fn test_shim_config_to_file() {
325        let config = ShimConfig {
326            shim: ShimCore {
327                name: "test".to_string(),
328                path: "echo".to_string(),
329                args: vec!["hello".to_string()],
330                cwd: None,
331            },
332            args: Default::default(),
333            env: {
334                let mut env = HashMap::new();
335                env.insert("TEST_VAR".to_string(), "test_value".to_string());
336                env
337            },
338            metadata: ShimMetadata {
339                description: Some("Test shim".to_string()),
340                version: Some("1.0.0".to_string()),
341                author: None,
342                tags: vec![],
343            },
344            auto_update: None,
345        };
346
347        let temp_file = NamedTempFile::new().unwrap();
348        config.to_file(temp_file.path()).unwrap();
349
350        // Read back and verify
351        let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
352        assert_eq!(loaded_config.shim.name, config.shim.name);
353        assert_eq!(loaded_config.shim.path, config.shim.path);
354        assert_eq!(loaded_config.shim.args, config.shim.args);
355        assert_eq!(loaded_config.env, config.env);
356    }
357
358    #[test]
359    fn test_expand_env_vars() {
360        std::env::set_var("TEST_VAR", "test_value");
361
362        let mut config = ShimConfig {
363            shim: ShimCore {
364                name: "test".to_string(),
365                path: "${TEST_VAR}/bin/test".to_string(),
366                args: vec!["${TEST_VAR}".to_string()],
367                cwd: Some("${TEST_VAR}/work".to_string()),
368            },
369            args: Default::default(),
370            env: {
371                let mut env = HashMap::new();
372                env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
373                env
374            },
375            metadata: Default::default(),
376            auto_update: None,
377        };
378
379        config.expand_env_vars().unwrap();
380
381        assert_eq!(config.shim.path, "test_value/bin/test");
382        assert_eq!(config.shim.args[0], "test_value");
383        assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
384        assert_eq!(
385            config.env.get("EXPANDED"),
386            Some(&"test_value_expanded".to_string())
387        );
388
389        std::env::remove_var("TEST_VAR");
390    }
391
392    #[test]
393    fn test_shim_config_with_args_template() {
394        let mut temp_file = NamedTempFile::new().unwrap();
395        write!(
396            temp_file,
397            r#"
398[shim]
399name = "test"
400path = "echo"
401
402[args]
403mode = "template"
404template = [
405    "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
406    "{{{{args('--version')}}}}"
407]
408
409[metadata]
410description = "Test shim with template args"
411        "#
412        )
413        .unwrap();
414
415        let config = ShimConfig::from_file(temp_file.path()).unwrap();
416        assert_eq!(config.shim.name, "test");
417        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
418        assert!(config.args.template.is_some());
419
420        let template = config.args.template.unwrap();
421        assert_eq!(template.len(), 2);
422        assert_eq!(
423            template[0],
424            "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
425        );
426        assert_eq!(template[1], "{{args('--version')}}");
427    }
428
429    #[test]
430    fn test_shim_config_with_args_modes() {
431        // Test merge mode
432        let mut temp_file = NamedTempFile::new().unwrap();
433        writeln!(
434            temp_file,
435            r#"
436[shim]
437name = "test"
438path = "echo"
439
440[args]
441mode = "merge"
442default = ["--default"]
443prefix = ["--prefix"]
444suffix = ["--suffix"]
445        "#
446        )
447        .unwrap();
448
449        let config = ShimConfig::from_file(temp_file.path()).unwrap();
450        assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
451        assert_eq!(config.args.default, vec!["--default"]);
452        assert_eq!(config.args.prefix, vec!["--prefix"]);
453        assert_eq!(config.args.suffix, vec!["--suffix"]);
454    }
455
456    #[test]
457    fn test_shim_config_with_inline_template() {
458        let mut temp_file = NamedTempFile::new().unwrap();
459        write!(
460            temp_file,
461            r#"
462[shim]
463name = "test"
464path = "echo"
465
466[args]
467inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
468        "#
469        )
470        .unwrap();
471
472        let config = ShimConfig::from_file(temp_file.path()).unwrap();
473        assert!(config.args.inline.is_some());
474        assert_eq!(
475            config.args.inline.unwrap(),
476            "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
477        );
478    }
479}