shimexe_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, SystemTime};
6
7use crate::error::{Result, ShimError};
8use crate::template::ArgsConfig;
9use crate::utils::expand_env_vars;
10
11/// Configuration cache entry
12#[derive(Debug, Clone)]
13struct CacheEntry {
14    config: ShimConfig,
15    last_modified: SystemTime,
16    cached_at: SystemTime,
17}
18
19/// Configuration cache for improved performance
20#[derive(Debug, Clone)]
21pub struct ConfigCache {
22    cache: Arc<Mutex<HashMap<PathBuf, CacheEntry>>>,
23    ttl: Duration,
24}
25
26impl ConfigCache {
27    /// Create a new configuration cache with specified TTL
28    pub fn new(ttl: Duration) -> Self {
29        Self {
30            cache: Arc::new(Mutex::new(HashMap::new())),
31            ttl,
32        }
33    }
34
35    /// Get configuration from cache or load from file
36    pub fn get_or_load<P: AsRef<Path>>(&self, path: P) -> Result<ShimConfig> {
37        let path = path.as_ref().to_path_buf();
38        let now = SystemTime::now();
39
40        // Check cache first
41        if let Ok(cache) = self.cache.lock() {
42            if let Some(entry) = cache.get(&path) {
43                // Check if cache entry is still valid
44                if now.duration_since(entry.cached_at).unwrap_or(Duration::MAX) < self.ttl {
45                    // Check if file hasn't been modified
46                    if let Ok(metadata) = std::fs::metadata(&path) {
47                        if let Ok(modified) = metadata.modified() {
48                            if modified <= entry.last_modified {
49                                return Ok(entry.config.clone());
50                            }
51                        }
52                    }
53                }
54            }
55        }
56
57        // Load from file and update cache
58        let config = ShimConfig::from_file(&path)?;
59        let last_modified = std::fs::metadata(&path)
60            .and_then(|m| m.modified())
61            .unwrap_or(now);
62
63        if let Ok(mut cache) = self.cache.lock() {
64            cache.insert(
65                path,
66                CacheEntry {
67                    config: config.clone(),
68                    last_modified,
69                    cached_at: now,
70                },
71            );
72        }
73
74        Ok(config)
75    }
76
77    /// Invalidate cache entry for a specific path
78    pub fn invalidate<P: AsRef<Path>>(&self, path: P) {
79        let path = path.as_ref().to_path_buf();
80        if let Ok(mut cache) = self.cache.lock() {
81            cache.remove(&path);
82        }
83    }
84
85    /// Clear all cache entries
86    pub fn clear(&self) {
87        if let Ok(mut cache) = self.cache.lock() {
88            cache.clear();
89        }
90    }
91
92    /// Get cache statistics
93    pub fn stats(&self) -> (usize, usize) {
94        if let Ok(cache) = self.cache.lock() {
95            let total = cache.len();
96            let now = SystemTime::now();
97            let valid = cache
98                .values()
99                .filter(|entry| {
100                    now.duration_since(entry.cached_at).unwrap_or(Duration::MAX) < self.ttl
101                })
102                .count();
103            (total, valid)
104        } else {
105            (0, 0)
106        }
107    }
108}
109
110impl Default for ConfigCache {
111    /// Create a new configuration cache with default TTL (5 minutes)
112    fn default() -> Self {
113        Self::new(Duration::from_secs(300))
114    }
115}
116
117/// Main shim configuration structure
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ShimConfig {
120    /// Core shim configuration
121    pub shim: ShimCore,
122    /// Advanced argument configuration
123    #[serde(default)]
124    pub args: ArgsConfig,
125    /// Environment variables to set
126    #[serde(default)]
127    pub env: HashMap<String, String>,
128    /// Optional metadata
129    #[serde(default)]
130    pub metadata: ShimMetadata,
131    /// Auto-update configuration
132    #[serde(default)]
133    pub auto_update: Option<AutoUpdate>,
134}
135
136/// Core shim configuration
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ShimCore {
139    /// Name of the shim
140    pub name: String,
141    /// Path to the target executable
142    pub path: String,
143    /// Default arguments to pass to the executable
144    #[serde(default)]
145    pub args: Vec<String>,
146    /// Working directory for the executable
147    #[serde(default)]
148    pub cwd: Option<String>,
149    /// Original download URL (for HTTP-based shims)
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub download_url: Option<String>,
152}
153
154/// Optional metadata for the shim
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct ShimMetadata {
157    /// Description of the shim
158    pub description: Option<String>,
159    /// Version of the shim configuration
160    pub version: Option<String>,
161    /// Author of the shim
162    pub author: Option<String>,
163    /// Tags for categorization
164    #[serde(default)]
165    pub tags: Vec<String>,
166}
167
168/// Auto-update configuration for the shim
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct AutoUpdate {
171    /// Enable auto-update
172    #[serde(default)]
173    pub enabled: bool,
174    /// Update provider type
175    pub provider: UpdateProvider,
176    /// Download URL template with version placeholder
177    pub download_url: String,
178    /// Version check URL or pattern
179    pub version_check: VersionCheck,
180    /// Update frequency in hours (0 = check every run)
181    #[serde(default)]
182    pub check_interval_hours: u64,
183    /// Pre-update command to run
184    pub pre_update_command: Option<String>,
185    /// Post-update command to run
186    pub post_update_command: Option<String>,
187}
188
189/// Update provider types
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum UpdateProvider {
193    /// GitHub releases
194    Github {
195        /// Repository owner/name
196        repo: String,
197        /// Asset name pattern (supports {version}, {os}, {arch} placeholders)
198        asset_pattern: String,
199        /// Include pre-releases
200        #[serde(default)]
201        include_prerelease: bool,
202    },
203    /// Direct HTTPS download
204    Https {
205        /// Base URL for downloads
206        base_url: String,
207        /// URL pattern for version checking
208        version_url: Option<String>,
209    },
210    /// Custom provider
211    Custom {
212        /// Custom update script or command
213        update_command: String,
214        /// Custom version check command
215        version_command: String,
216    },
217}
218
219/// Version check configuration
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum VersionCheck {
223    /// GitHub API for latest release
224    GithubLatest {
225        /// Repository owner/name
226        repo: String,
227        /// Include pre-releases
228        #[serde(default)]
229        include_prerelease: bool,
230    },
231    /// HTTP endpoint returning version
232    Http {
233        /// URL to check for version
234        url: String,
235        /// JSON path to extract version (e.g., "$.version")
236        json_path: Option<String>,
237        /// Regex pattern to extract version
238        regex_pattern: Option<String>,
239    },
240    /// Semantic version comparison
241    Semver {
242        /// Current version
243        current: String,
244        /// Version check URL
245        check_url: String,
246    },
247    /// Custom version check command
248    Command {
249        /// Command to run for version check
250        command: String,
251        /// Arguments for the command
252        args: Vec<String>,
253    },
254}
255
256impl ShimConfig {
257    /// Load shim configuration from a TOML file
258    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
259        let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
260
261        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
262
263        config.validate()?;
264        Ok(config)
265    }
266
267    /// Load shim configuration from a TOML file asynchronously
268    pub async fn from_file_async<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
269        let content = tokio::fs::read_to_string(&path)
270            .await
271            .map_err(ShimError::Io)?;
272
273        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
274
275        config.validate()?;
276        Ok(config)
277    }
278
279    /// Save shim configuration to a TOML file
280    pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
281        // Check if file already exists and has the same content to avoid unnecessary writes
282        if let Ok(existing_content) = std::fs::read_to_string(&path) {
283            let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
284            if existing_content.trim() == new_content.trim() {
285                return Ok(()); // No changes needed
286            }
287        }
288
289        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
290        std::fs::write(path, content).map_err(ShimError::Io)?;
291
292        Ok(())
293    }
294
295    /// Save shim configuration to a TOML file asynchronously
296    pub async fn to_file_async<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
297        // Check if file already exists and has the same content to avoid unnecessary writes
298        if let Ok(existing_content) = tokio::fs::read_to_string(&path).await {
299            let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
300            if existing_content.trim() == new_content.trim() {
301                return Ok(()); // No changes needed
302            }
303        }
304
305        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
306        tokio::fs::write(path, content)
307            .await
308            .map_err(ShimError::Io)?;
309
310        Ok(())
311    }
312
313    /// Load multiple configuration files concurrently
314    pub async fn from_files_concurrent<P: AsRef<std::path::Path>>(
315        paths: Vec<P>,
316    ) -> Vec<Result<Self>> {
317        use futures_util::future::join_all;
318
319        let futures = paths
320            .into_iter()
321            .map(|path| Self::from_file_async(path))
322            .collect::<Vec<_>>();
323
324        join_all(futures).await
325    }
326
327    /// Save multiple configurations concurrently
328    pub async fn to_files_concurrent<P: AsRef<std::path::Path>>(
329        configs_and_paths: Vec<(&Self, P)>,
330    ) -> Vec<Result<()>> {
331        use futures_util::future::join_all;
332
333        let futures = configs_and_paths
334            .into_iter()
335            .map(|(config, path)| config.to_file_async(path))
336            .collect::<Vec<_>>();
337
338        join_all(futures).await
339    }
340
341    /// Validate the configuration
342    pub fn validate(&self) -> Result<()> {
343        if self.shim.name.is_empty() {
344            return Err(ShimError::Config("Shim name cannot be empty".to_string()));
345        }
346
347        if self.shim.path.is_empty() {
348            return Err(ShimError::Config("Shim path cannot be empty".to_string()));
349        }
350
351        Ok(())
352    }
353
354    /// Expand environment variables in the configuration
355    pub fn expand_env_vars(&mut self) -> Result<()> {
356        // Expand path
357        self.shim.path = expand_env_vars(&self.shim.path)?;
358
359        // Expand args
360        for arg in &mut self.shim.args {
361            *arg = expand_env_vars(arg)?;
362        }
363
364        // Expand cwd if present
365        if let Some(ref mut cwd) = self.shim.cwd {
366            *cwd = expand_env_vars(cwd)?;
367        }
368
369        // Expand environment variables
370        for value in self.env.values_mut() {
371            *value = expand_env_vars(value)?;
372        }
373
374        Ok(())
375    }
376
377    /// Get the resolved executable path
378    pub fn get_executable_path(&self) -> Result<PathBuf> {
379        let expanded_path = expand_env_vars(&self.shim.path)?;
380
381        // Check if we have a download_url (indicating this was originally from HTTP)
382        if let Some(ref download_url) = self.shim.download_url {
383            // This shim was created from an HTTP URL
384            let filename = crate::downloader::Downloader::extract_filename_from_url(download_url)
385                .ok_or_else(|| {
386                ShimError::Config(format!(
387                    "Could not extract filename from download URL: {}",
388                    download_url
389                ))
390            })?;
391
392            // Try to find the downloaded file in the expected location
393            // First try relative to home directory
394            if let Some(home_dir) = dirs::home_dir() {
395                let download_path = home_dir
396                    .join(".shimexe")
397                    .join(&self.shim.name)
398                    .join("bin")
399                    .join(&filename);
400
401                if download_path.exists() {
402                    return Ok(download_path);
403                }
404            }
405
406            // If not found, return an error indicating download is needed
407            Err(ShimError::ExecutableNotFound(format!(
408                "Executable not found for download URL: {}. Download may be required.",
409                download_url
410            )))
411        } else if crate::downloader::Downloader::is_url(&expanded_path) {
412            // Legacy: path is still a URL (for backward compatibility)
413            let filename = crate::downloader::Downloader::extract_filename_from_url(&expanded_path)
414                .ok_or_else(|| {
415                    ShimError::Config(format!(
416                        "Could not extract filename from URL: {}",
417                        expanded_path
418                    ))
419                })?;
420
421            // Try to find the downloaded file in the expected location
422            if let Some(home_dir) = dirs::home_dir() {
423                let download_path = home_dir
424                    .join(".shimexe")
425                    .join(&self.shim.name)
426                    .join("bin")
427                    .join(&filename);
428
429                if download_path.exists() {
430                    return Ok(download_path);
431                }
432            }
433
434            // If not found, return an error indicating download is needed
435            Err(ShimError::ExecutableNotFound(format!(
436                "Executable not found for URL: {}. Download may be required.",
437                expanded_path
438            )))
439        } else {
440            let path = PathBuf::from(expanded_path);
441
442            if path.is_absolute() {
443                Ok(path)
444            } else {
445                // Try to find in PATH
446                which::which(&path)
447                    .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
448            }
449        }
450    }
451
452    /// Get the download URL for this shim (if it was created from HTTP)
453    pub fn get_download_url(&self) -> Option<&String> {
454        self.shim.download_url.as_ref()
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::io::Write;
462    use tempfile::NamedTempFile;
463
464    #[test]
465    fn test_shim_config_from_file() {
466        let mut temp_file = NamedTempFile::new().unwrap();
467        writeln!(
468            temp_file,
469            r#"
470[shim]
471name = "test"
472path = "echo"
473args = ["hello"]
474
475[env]
476TEST_VAR = "test_value"
477
478[metadata]
479description = "Test shim"
480version = "1.0.0"
481        "#
482        )
483        .unwrap();
484
485        let config = ShimConfig::from_file(temp_file.path()).unwrap();
486        assert_eq!(config.shim.name, "test");
487        assert_eq!(config.shim.path, "echo");
488        assert_eq!(config.shim.args, vec!["hello"]);
489        assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
490        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
491    }
492
493    #[test]
494    fn test_shim_config_basic_structure() {
495        let mut temp_file = NamedTempFile::new().unwrap();
496        writeln!(
497            temp_file,
498            r#"
499[shim]
500name = "test"
501path = "echo"
502
503[args]
504mode = "template"
505
506[metadata]
507description = "Test shim"
508        "#
509        )
510        .unwrap();
511
512        let config = ShimConfig::from_file(temp_file.path()).unwrap();
513        assert_eq!(config.shim.name, "test");
514        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
515        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
516    }
517
518    #[test]
519    fn test_shim_config_validation() {
520        // Valid config
521        let config = ShimConfig {
522            shim: ShimCore {
523                name: "test".to_string(),
524                path: "echo".to_string(),
525                args: vec![],
526                cwd: None,
527                download_url: None,
528            },
529            args: Default::default(),
530            env: HashMap::new(),
531            metadata: Default::default(),
532            auto_update: None,
533        };
534        assert!(config.validate().is_ok());
535
536        // Invalid config - empty name
537        let invalid_config = ShimConfig {
538            shim: ShimCore {
539                name: "".to_string(),
540                path: "echo".to_string(),
541                args: vec![],
542                cwd: None,
543                download_url: None,
544            },
545            args: Default::default(),
546            env: HashMap::new(),
547            metadata: Default::default(),
548            auto_update: None,
549        };
550        assert!(invalid_config.validate().is_err());
551
552        // Invalid config - empty path
553        let invalid_config = ShimConfig {
554            shim: ShimCore {
555                name: "test".to_string(),
556                path: "".to_string(),
557                args: vec![],
558                cwd: None,
559                download_url: None,
560            },
561            args: Default::default(),
562            env: HashMap::new(),
563            metadata: Default::default(),
564            auto_update: None,
565        };
566        assert!(invalid_config.validate().is_err());
567    }
568
569    #[test]
570    fn test_shim_config_to_file() {
571        let config = ShimConfig {
572            shim: ShimCore {
573                name: "test".to_string(),
574                path: "echo".to_string(),
575                args: vec!["hello".to_string()],
576                cwd: None,
577                download_url: None,
578            },
579            args: Default::default(),
580            env: {
581                let mut env = HashMap::new();
582                env.insert("TEST_VAR".to_string(), "test_value".to_string());
583                env
584            },
585            metadata: ShimMetadata {
586                description: Some("Test shim".to_string()),
587                version: Some("1.0.0".to_string()),
588                author: None,
589                tags: vec![],
590            },
591            auto_update: None,
592        };
593
594        let temp_file = NamedTempFile::new().unwrap();
595        config.to_file(temp_file.path()).unwrap();
596
597        // Read back and verify
598        let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
599        assert_eq!(loaded_config.shim.name, config.shim.name);
600        assert_eq!(loaded_config.shim.path, config.shim.path);
601        assert_eq!(loaded_config.shim.args, config.shim.args);
602        assert_eq!(loaded_config.env, config.env);
603    }
604
605    #[test]
606    fn test_expand_env_vars() {
607        std::env::set_var("TEST_VAR", "test_value");
608
609        let mut config = ShimConfig {
610            shim: ShimCore {
611                name: "test".to_string(),
612                path: "${TEST_VAR}/bin/test".to_string(),
613                args: vec!["${TEST_VAR}".to_string()],
614                cwd: Some("${TEST_VAR}/work".to_string()),
615                download_url: None,
616            },
617            args: Default::default(),
618            env: {
619                let mut env = HashMap::new();
620                env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
621                env
622            },
623            metadata: Default::default(),
624            auto_update: None,
625        };
626
627        config.expand_env_vars().unwrap();
628
629        assert_eq!(config.shim.path, "test_value/bin/test");
630        assert_eq!(config.shim.args[0], "test_value");
631        assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
632        assert_eq!(
633            config.env.get("EXPANDED"),
634            Some(&"test_value_expanded".to_string())
635        );
636
637        std::env::remove_var("TEST_VAR");
638    }
639
640    #[test]
641    fn test_shim_config_with_args_template() {
642        let mut temp_file = NamedTempFile::new().unwrap();
643        write!(
644            temp_file,
645            r#"
646[shim]
647name = "test"
648path = "echo"
649
650[args]
651mode = "template"
652template = [
653    "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
654    "{{{{args('--version')}}}}"
655]
656
657[metadata]
658description = "Test shim with template args"
659        "#
660        )
661        .unwrap();
662
663        let config = ShimConfig::from_file(temp_file.path()).unwrap();
664        assert_eq!(config.shim.name, "test");
665        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
666        assert!(config.args.template.is_some());
667
668        let template = config.args.template.unwrap();
669        assert_eq!(template.len(), 2);
670        assert_eq!(
671            template[0],
672            "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
673        );
674        assert_eq!(template[1], "{{args('--version')}}");
675    }
676
677    #[test]
678    fn test_shim_config_with_args_modes() {
679        // Test merge mode
680        let mut temp_file = NamedTempFile::new().unwrap();
681        writeln!(
682            temp_file,
683            r#"
684[shim]
685name = "test"
686path = "echo"
687
688[args]
689mode = "merge"
690default = ["--default"]
691prefix = ["--prefix"]
692suffix = ["--suffix"]
693        "#
694        )
695        .unwrap();
696
697        let config = ShimConfig::from_file(temp_file.path()).unwrap();
698        assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
699        assert_eq!(config.args.default, vec!["--default"]);
700        assert_eq!(config.args.prefix, vec!["--prefix"]);
701        assert_eq!(config.args.suffix, vec!["--suffix"]);
702    }
703
704    #[test]
705    fn test_shim_config_with_inline_template() {
706        let mut temp_file = NamedTempFile::new().unwrap();
707        write!(
708            temp_file,
709            r#"
710[shim]
711name = "test"
712path = "echo"
713
714[args]
715inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
716        "#
717        )
718        .unwrap();
719
720        let config = ShimConfig::from_file(temp_file.path()).unwrap();
721        assert!(config.args.inline.is_some());
722        assert_eq!(
723            config.args.inline.unwrap(),
724            "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
725        );
726    }
727
728    #[test]
729    fn test_shim_config_with_download_url() {
730        let mut temp_file = NamedTempFile::new().unwrap();
731        writeln!(
732            temp_file,
733            r#"
734[shim]
735name = "test-tool"
736path = "/home/user/.shimexe/test-tool/bin/test-tool.exe"
737download_url = "https://example.com/test-tool.exe"
738
739[metadata]
740description = "Test shim with download URL"
741        "#
742        )
743        .unwrap();
744
745        let config = ShimConfig::from_file(temp_file.path()).unwrap();
746        assert_eq!(config.shim.name, "test-tool");
747        assert_eq!(
748            config.shim.path,
749            "/home/user/.shimexe/test-tool/bin/test-tool.exe"
750        );
751        assert_eq!(
752            config.shim.download_url,
753            Some("https://example.com/test-tool.exe".to_string())
754        );
755        assert_eq!(
756            config.get_download_url(),
757            Some(&"https://example.com/test-tool.exe".to_string())
758        );
759    }
760
761    #[test]
762    fn test_shim_config_without_download_url() {
763        let mut temp_file = NamedTempFile::new().unwrap();
764        writeln!(
765            temp_file,
766            r#"
767[shim]
768name = "local-tool"
769path = "/usr/bin/local-tool"
770
771[metadata]
772description = "Test shim without download URL"
773        "#
774        )
775        .unwrap();
776
777        let config = ShimConfig::from_file(temp_file.path()).unwrap();
778        assert_eq!(config.shim.name, "local-tool");
779        assert_eq!(config.shim.path, "/usr/bin/local-tool");
780        assert_eq!(config.shim.download_url, None);
781        assert_eq!(config.get_download_url(), None);
782    }
783}