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    /// Original download URL (for HTTP-based shims)
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub download_url: Option<String>,
44}
45
46/// Optional metadata for the shim
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct ShimMetadata {
49    /// Description of the shim
50    pub description: Option<String>,
51    /// Version of the shim configuration
52    pub version: Option<String>,
53    /// Author of the shim
54    pub author: Option<String>,
55    /// Tags for categorization
56    #[serde(default)]
57    pub tags: Vec<String>,
58}
59
60/// Auto-update configuration for the shim
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AutoUpdate {
63    /// Enable auto-update
64    #[serde(default)]
65    pub enabled: bool,
66    /// Update provider type
67    pub provider: UpdateProvider,
68    /// Download URL template with version placeholder
69    pub download_url: String,
70    /// Version check URL or pattern
71    pub version_check: VersionCheck,
72    /// Update frequency in hours (0 = check every run)
73    #[serde(default)]
74    pub check_interval_hours: u64,
75    /// Pre-update command to run
76    pub pre_update_command: Option<String>,
77    /// Post-update command to run
78    pub post_update_command: Option<String>,
79}
80
81/// Update provider types
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum UpdateProvider {
85    /// GitHub releases
86    Github {
87        /// Repository owner/name
88        repo: String,
89        /// Asset name pattern (supports {version}, {os}, {arch} placeholders)
90        asset_pattern: String,
91        /// Include pre-releases
92        #[serde(default)]
93        include_prerelease: bool,
94    },
95    /// Direct HTTPS download
96    Https {
97        /// Base URL for downloads
98        base_url: String,
99        /// URL pattern for version checking
100        version_url: Option<String>,
101    },
102    /// Custom provider
103    Custom {
104        /// Custom update script or command
105        update_command: String,
106        /// Custom version check command
107        version_command: String,
108    },
109}
110
111/// Version check configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum VersionCheck {
115    /// GitHub API for latest release
116    GithubLatest {
117        /// Repository owner/name
118        repo: String,
119        /// Include pre-releases
120        #[serde(default)]
121        include_prerelease: bool,
122    },
123    /// HTTP endpoint returning version
124    Http {
125        /// URL to check for version
126        url: String,
127        /// JSON path to extract version (e.g., "$.version")
128        json_path: Option<String>,
129        /// Regex pattern to extract version
130        regex_pattern: Option<String>,
131    },
132    /// Semantic version comparison
133    Semver {
134        /// Current version
135        current: String,
136        /// Version check URL
137        check_url: String,
138    },
139    /// Custom version check command
140    Command {
141        /// Command to run for version check
142        command: String,
143        /// Arguments for the command
144        args: Vec<String>,
145    },
146}
147
148impl ShimConfig {
149    /// Load shim configuration from a TOML file
150    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
151        let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
152
153        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
154
155        config.validate()?;
156        Ok(config)
157    }
158
159    /// Save shim configuration to a TOML file
160    pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
161        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
162
163        std::fs::write(path, content).map_err(ShimError::Io)?;
164
165        Ok(())
166    }
167
168    /// Validate the configuration
169    pub fn validate(&self) -> Result<()> {
170        if self.shim.name.is_empty() {
171            return Err(ShimError::Config("Shim name cannot be empty".to_string()));
172        }
173
174        if self.shim.path.is_empty() {
175            return Err(ShimError::Config("Shim path cannot be empty".to_string()));
176        }
177
178        Ok(())
179    }
180
181    /// Expand environment variables in the configuration
182    pub fn expand_env_vars(&mut self) -> Result<()> {
183        // Expand path
184        self.shim.path = expand_env_vars(&self.shim.path)?;
185
186        // Expand args
187        for arg in &mut self.shim.args {
188            *arg = expand_env_vars(arg)?;
189        }
190
191        // Expand cwd if present
192        if let Some(ref mut cwd) = self.shim.cwd {
193            *cwd = expand_env_vars(cwd)?;
194        }
195
196        // Expand environment variables
197        for value in self.env.values_mut() {
198            *value = expand_env_vars(value)?;
199        }
200
201        Ok(())
202    }
203
204    /// Get the resolved executable path
205    pub fn get_executable_path(&self) -> Result<PathBuf> {
206        let expanded_path = expand_env_vars(&self.shim.path)?;
207
208        // Check if we have a download_url (indicating this was originally from HTTP)
209        if let Some(ref download_url) = self.shim.download_url {
210            // This shim was created from an HTTP URL
211            let filename = crate::downloader::Downloader::extract_filename_from_url(download_url)
212                .ok_or_else(|| {
213                ShimError::Config(format!(
214                    "Could not extract filename from download URL: {}",
215                    download_url
216                ))
217            })?;
218
219            // Try to find the downloaded file in the expected location
220            // First try relative to home directory
221            if let Some(home_dir) = dirs::home_dir() {
222                let download_path = home_dir
223                    .join(".shimexe")
224                    .join(&self.shim.name)
225                    .join("bin")
226                    .join(&filename);
227
228                if download_path.exists() {
229                    return Ok(download_path);
230                }
231            }
232
233            // If not found, return an error indicating download is needed
234            Err(ShimError::ExecutableNotFound(format!(
235                "Executable not found for download URL: {}. Download may be required.",
236                download_url
237            )))
238        } else if crate::downloader::Downloader::is_url(&expanded_path) {
239            // Legacy: path is still a URL (for backward compatibility)
240            let filename = crate::downloader::Downloader::extract_filename_from_url(&expanded_path)
241                .ok_or_else(|| {
242                    ShimError::Config(format!(
243                        "Could not extract filename from URL: {}",
244                        expanded_path
245                    ))
246                })?;
247
248            // Try to find the downloaded file in the expected location
249            if let Some(home_dir) = dirs::home_dir() {
250                let download_path = home_dir
251                    .join(".shimexe")
252                    .join(&self.shim.name)
253                    .join("bin")
254                    .join(&filename);
255
256                if download_path.exists() {
257                    return Ok(download_path);
258                }
259            }
260
261            // If not found, return an error indicating download is needed
262            Err(ShimError::ExecutableNotFound(format!(
263                "Executable not found for URL: {}. Download may be required.",
264                expanded_path
265            )))
266        } else {
267            let path = PathBuf::from(expanded_path);
268
269            if path.is_absolute() {
270                Ok(path)
271            } else {
272                // Try to find in PATH
273                which::which(&path)
274                    .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
275            }
276        }
277    }
278
279    /// Get the download URL for this shim (if it was created from HTTP)
280    pub fn get_download_url(&self) -> Option<&String> {
281        self.shim.download_url.as_ref()
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::io::Write;
289    use tempfile::NamedTempFile;
290
291    #[test]
292    fn test_shim_config_from_file() {
293        let mut temp_file = NamedTempFile::new().unwrap();
294        writeln!(
295            temp_file,
296            r#"
297[shim]
298name = "test"
299path = "echo"
300args = ["hello"]
301
302[env]
303TEST_VAR = "test_value"
304
305[metadata]
306description = "Test shim"
307version = "1.0.0"
308        "#
309        )
310        .unwrap();
311
312        let config = ShimConfig::from_file(temp_file.path()).unwrap();
313        assert_eq!(config.shim.name, "test");
314        assert_eq!(config.shim.path, "echo");
315        assert_eq!(config.shim.args, vec!["hello"]);
316        assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
317        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
318    }
319
320    #[test]
321    fn test_shim_config_basic_structure() {
322        let mut temp_file = NamedTempFile::new().unwrap();
323        writeln!(
324            temp_file,
325            r#"
326[shim]
327name = "test"
328path = "echo"
329
330[args]
331mode = "template"
332
333[metadata]
334description = "Test shim"
335        "#
336        )
337        .unwrap();
338
339        let config = ShimConfig::from_file(temp_file.path()).unwrap();
340        assert_eq!(config.shim.name, "test");
341        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
342        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
343    }
344
345    #[test]
346    fn test_shim_config_validation() {
347        // Valid config
348        let config = ShimConfig {
349            shim: ShimCore {
350                name: "test".to_string(),
351                path: "echo".to_string(),
352                args: vec![],
353                cwd: None,
354                download_url: None,
355            },
356            args: Default::default(),
357            env: HashMap::new(),
358            metadata: Default::default(),
359            auto_update: None,
360        };
361        assert!(config.validate().is_ok());
362
363        // Invalid config - empty name
364        let invalid_config = ShimConfig {
365            shim: ShimCore {
366                name: "".to_string(),
367                path: "echo".to_string(),
368                args: vec![],
369                cwd: None,
370                download_url: None,
371            },
372            args: Default::default(),
373            env: HashMap::new(),
374            metadata: Default::default(),
375            auto_update: None,
376        };
377        assert!(invalid_config.validate().is_err());
378
379        // Invalid config - empty path
380        let invalid_config = ShimConfig {
381            shim: ShimCore {
382                name: "test".to_string(),
383                path: "".to_string(),
384                args: vec![],
385                cwd: None,
386                download_url: None,
387            },
388            args: Default::default(),
389            env: HashMap::new(),
390            metadata: Default::default(),
391            auto_update: None,
392        };
393        assert!(invalid_config.validate().is_err());
394    }
395
396    #[test]
397    fn test_shim_config_to_file() {
398        let config = ShimConfig {
399            shim: ShimCore {
400                name: "test".to_string(),
401                path: "echo".to_string(),
402                args: vec!["hello".to_string()],
403                cwd: None,
404                download_url: None,
405            },
406            args: Default::default(),
407            env: {
408                let mut env = HashMap::new();
409                env.insert("TEST_VAR".to_string(), "test_value".to_string());
410                env
411            },
412            metadata: ShimMetadata {
413                description: Some("Test shim".to_string()),
414                version: Some("1.0.0".to_string()),
415                author: None,
416                tags: vec![],
417            },
418            auto_update: None,
419        };
420
421        let temp_file = NamedTempFile::new().unwrap();
422        config.to_file(temp_file.path()).unwrap();
423
424        // Read back and verify
425        let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
426        assert_eq!(loaded_config.shim.name, config.shim.name);
427        assert_eq!(loaded_config.shim.path, config.shim.path);
428        assert_eq!(loaded_config.shim.args, config.shim.args);
429        assert_eq!(loaded_config.env, config.env);
430    }
431
432    #[test]
433    fn test_expand_env_vars() {
434        std::env::set_var("TEST_VAR", "test_value");
435
436        let mut config = ShimConfig {
437            shim: ShimCore {
438                name: "test".to_string(),
439                path: "${TEST_VAR}/bin/test".to_string(),
440                args: vec!["${TEST_VAR}".to_string()],
441                cwd: Some("${TEST_VAR}/work".to_string()),
442                download_url: None,
443            },
444            args: Default::default(),
445            env: {
446                let mut env = HashMap::new();
447                env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
448                env
449            },
450            metadata: Default::default(),
451            auto_update: None,
452        };
453
454        config.expand_env_vars().unwrap();
455
456        assert_eq!(config.shim.path, "test_value/bin/test");
457        assert_eq!(config.shim.args[0], "test_value");
458        assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
459        assert_eq!(
460            config.env.get("EXPANDED"),
461            Some(&"test_value_expanded".to_string())
462        );
463
464        std::env::remove_var("TEST_VAR");
465    }
466
467    #[test]
468    fn test_shim_config_with_args_template() {
469        let mut temp_file = NamedTempFile::new().unwrap();
470        write!(
471            temp_file,
472            r#"
473[shim]
474name = "test"
475path = "echo"
476
477[args]
478mode = "template"
479template = [
480    "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
481    "{{{{args('--version')}}}}"
482]
483
484[metadata]
485description = "Test shim with template args"
486        "#
487        )
488        .unwrap();
489
490        let config = ShimConfig::from_file(temp_file.path()).unwrap();
491        assert_eq!(config.shim.name, "test");
492        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
493        assert!(config.args.template.is_some());
494
495        let template = config.args.template.unwrap();
496        assert_eq!(template.len(), 2);
497        assert_eq!(
498            template[0],
499            "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
500        );
501        assert_eq!(template[1], "{{args('--version')}}");
502    }
503
504    #[test]
505    fn test_shim_config_with_args_modes() {
506        // Test merge mode
507        let mut temp_file = NamedTempFile::new().unwrap();
508        writeln!(
509            temp_file,
510            r#"
511[shim]
512name = "test"
513path = "echo"
514
515[args]
516mode = "merge"
517default = ["--default"]
518prefix = ["--prefix"]
519suffix = ["--suffix"]
520        "#
521        )
522        .unwrap();
523
524        let config = ShimConfig::from_file(temp_file.path()).unwrap();
525        assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
526        assert_eq!(config.args.default, vec!["--default"]);
527        assert_eq!(config.args.prefix, vec!["--prefix"]);
528        assert_eq!(config.args.suffix, vec!["--suffix"]);
529    }
530
531    #[test]
532    fn test_shim_config_with_inline_template() {
533        let mut temp_file = NamedTempFile::new().unwrap();
534        write!(
535            temp_file,
536            r#"
537[shim]
538name = "test"
539path = "echo"
540
541[args]
542inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
543        "#
544        )
545        .unwrap();
546
547        let config = ShimConfig::from_file(temp_file.path()).unwrap();
548        assert!(config.args.inline.is_some());
549        assert_eq!(
550            config.args.inline.unwrap(),
551            "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
552        );
553    }
554
555    #[test]
556    fn test_shim_config_with_download_url() {
557        let mut temp_file = NamedTempFile::new().unwrap();
558        writeln!(
559            temp_file,
560            r#"
561[shim]
562name = "test-tool"
563path = "/home/user/.shimexe/test-tool/bin/test-tool.exe"
564download_url = "https://example.com/test-tool.exe"
565
566[metadata]
567description = "Test shim with download URL"
568        "#
569        )
570        .unwrap();
571
572        let config = ShimConfig::from_file(temp_file.path()).unwrap();
573        assert_eq!(config.shim.name, "test-tool");
574        assert_eq!(
575            config.shim.path,
576            "/home/user/.shimexe/test-tool/bin/test-tool.exe"
577        );
578        assert_eq!(
579            config.shim.download_url,
580            Some("https://example.com/test-tool.exe".to_string())
581        );
582        assert_eq!(
583            config.get_download_url(),
584            Some(&"https://example.com/test-tool.exe".to_string())
585        );
586    }
587
588    #[test]
589    fn test_shim_config_without_download_url() {
590        let mut temp_file = NamedTempFile::new().unwrap();
591        writeln!(
592            temp_file,
593            r#"
594[shim]
595name = "local-tool"
596path = "/usr/bin/local-tool"
597
598[metadata]
599description = "Test shim without download URL"
600        "#
601        )
602        .unwrap();
603
604        let config = ShimConfig::from_file(temp_file.path()).unwrap();
605        assert_eq!(config.shim.name, "local-tool");
606        assert_eq!(config.shim.path, "/usr/bin/local-tool");
607        assert_eq!(config.shim.download_url, None);
608        assert_eq!(config.get_download_url(), None);
609    }
610}