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    /// Type of source (file, archive, url)
153    #[serde(default)]
154    pub source_type: SourceType,
155    /// For archives: list of extracted executables
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub extracted_executables: Vec<ExtractedExecutable>,
158}
159
160/// Source type for the shim
161#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "lowercase")]
163pub enum SourceType {
164    /// Regular file or executable
165    #[default]
166    File,
167    /// Archive file (zip, tar.gz, etc.)
168    Archive,
169    /// HTTP URL
170    Url,
171}
172
173/// Information about an extracted executable from an archive
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ExtractedExecutable {
176    /// Name of the executable (without extension)
177    pub name: String,
178    /// Relative path within the extracted archive
179    pub path: String,
180    /// Full path to the executable
181    pub full_path: String,
182    /// Whether this executable is the primary one for this shim
183    #[serde(default)]
184    pub is_primary: bool,
185}
186
187/// Optional metadata for the shim
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct ShimMetadata {
190    /// Description of the shim
191    pub description: Option<String>,
192    /// Version of the shim configuration
193    pub version: Option<String>,
194    /// Author of the shim
195    pub author: Option<String>,
196    /// Tags for categorization
197    #[serde(default)]
198    pub tags: Vec<String>,
199}
200
201/// Auto-update configuration for the shim
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct AutoUpdate {
204    /// Enable auto-update
205    #[serde(default)]
206    pub enabled: bool,
207    /// Update provider type
208    pub provider: UpdateProvider,
209    /// Download URL template with version placeholder
210    pub download_url: String,
211    /// Version check URL or pattern
212    pub version_check: VersionCheck,
213    /// Update frequency in hours (0 = check every run)
214    #[serde(default)]
215    pub check_interval_hours: u64,
216    /// Pre-update command to run
217    pub pre_update_command: Option<String>,
218    /// Post-update command to run
219    pub post_update_command: Option<String>,
220}
221
222/// Update provider types
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum UpdateProvider {
226    /// GitHub releases
227    Github {
228        /// Repository owner/name
229        repo: String,
230        /// Asset name pattern (supports {version}, {os}, {arch} placeholders)
231        asset_pattern: String,
232        /// Include pre-releases
233        #[serde(default)]
234        include_prerelease: bool,
235    },
236    /// Direct HTTPS download
237    Https {
238        /// Base URL for downloads
239        base_url: String,
240        /// URL pattern for version checking
241        version_url: Option<String>,
242    },
243    /// Custom provider
244    Custom {
245        /// Custom update script or command
246        update_command: String,
247        /// Custom version check command
248        version_command: String,
249    },
250}
251
252/// Version check configuration
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum VersionCheck {
256    /// GitHub API for latest release
257    GithubLatest {
258        /// Repository owner/name
259        repo: String,
260        /// Include pre-releases
261        #[serde(default)]
262        include_prerelease: bool,
263    },
264    /// HTTP endpoint returning version
265    Http {
266        /// URL to check for version
267        url: String,
268        /// JSON path to extract version (e.g., "$.version")
269        json_path: Option<String>,
270        /// Regex pattern to extract version
271        regex_pattern: Option<String>,
272    },
273    /// Semantic version comparison
274    Semver {
275        /// Current version
276        current: String,
277        /// Version check URL
278        check_url: String,
279    },
280    /// Custom version check command
281    Command {
282        /// Command to run for version check
283        command: String,
284        /// Arguments for the command
285        args: Vec<String>,
286    },
287}
288
289impl ShimConfig {
290    /// Load shim configuration from a TOML file
291    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
292        let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
293
294        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
295
296        config.validate()?;
297        Ok(config)
298    }
299
300    /// Load shim configuration from a TOML file asynchronously
301    pub async fn from_file_async<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
302        let content = tokio::fs::read_to_string(&path)
303            .await
304            .map_err(ShimError::Io)?;
305
306        let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
307
308        config.validate()?;
309        Ok(config)
310    }
311
312    /// Save shim configuration to a TOML file
313    pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
314        // Check if file already exists and has the same content to avoid unnecessary writes
315        if let Ok(existing_content) = std::fs::read_to_string(&path) {
316            let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
317            if existing_content.trim() == new_content.trim() {
318                return Ok(()); // No changes needed
319            }
320        }
321
322        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
323        std::fs::write(path, content).map_err(ShimError::Io)?;
324
325        Ok(())
326    }
327
328    /// Save shim configuration to a TOML file asynchronously
329    pub async fn to_file_async<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
330        // Check if file already exists and has the same content to avoid unnecessary writes
331        if let Ok(existing_content) = tokio::fs::read_to_string(&path).await {
332            let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
333            if existing_content.trim() == new_content.trim() {
334                return Ok(()); // No changes needed
335            }
336        }
337
338        let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
339        tokio::fs::write(path, content)
340            .await
341            .map_err(ShimError::Io)?;
342
343        Ok(())
344    }
345
346    /// Load multiple configuration files concurrently
347    pub async fn from_files_concurrent<P: AsRef<std::path::Path>>(
348        paths: Vec<P>,
349    ) -> Vec<Result<Self>> {
350        use futures_util::future::join_all;
351
352        let futures = paths
353            .into_iter()
354            .map(|path| Self::from_file_async(path))
355            .collect::<Vec<_>>();
356
357        join_all(futures).await
358    }
359
360    /// Save multiple configurations concurrently
361    pub async fn to_files_concurrent<P: AsRef<std::path::Path>>(
362        configs_and_paths: Vec<(&Self, P)>,
363    ) -> Vec<Result<()>> {
364        use futures_util::future::join_all;
365
366        let futures = configs_and_paths
367            .into_iter()
368            .map(|(config, path)| config.to_file_async(path))
369            .collect::<Vec<_>>();
370
371        join_all(futures).await
372    }
373
374    /// Validate the configuration
375    pub fn validate(&self) -> Result<()> {
376        if self.shim.name.is_empty() {
377            return Err(ShimError::Config("Shim name cannot be empty".to_string()));
378        }
379
380        if self.shim.path.is_empty() {
381            return Err(ShimError::Config("Shim path cannot be empty".to_string()));
382        }
383
384        Ok(())
385    }
386
387    /// Expand environment variables in the configuration
388    pub fn expand_env_vars(&mut self) -> Result<()> {
389        // Expand path
390        self.shim.path = expand_env_vars(&self.shim.path)?;
391
392        // Expand args
393        for arg in &mut self.shim.args {
394            *arg = expand_env_vars(arg)?;
395        }
396
397        // Expand cwd if present
398        if let Some(ref mut cwd) = self.shim.cwd {
399            *cwd = expand_env_vars(cwd)?;
400        }
401
402        // Expand environment variables
403        for value in self.env.values_mut() {
404            *value = expand_env_vars(value)?;
405        }
406
407        Ok(())
408    }
409
410    /// Get the resolved executable path
411    pub fn get_executable_path(&self) -> Result<PathBuf> {
412        let expanded_path = expand_env_vars(&self.shim.path)?;
413
414        match self.shim.source_type {
415            SourceType::Archive => {
416                // For archives, use the primary executable or the first one
417                if let Some(primary_exe) = self
418                    .shim
419                    .extracted_executables
420                    .iter()
421                    .find(|exe| exe.is_primary)
422                    .or_else(|| self.shim.extracted_executables.first())
423                {
424                    let path = PathBuf::from(&primary_exe.full_path);
425                    if path.exists() {
426                        Ok(path)
427                    } else {
428                        Err(ShimError::ExecutableNotFound(format!(
429                            "Extracted executable not found: {}. Re-extraction may be required.",
430                            primary_exe.full_path
431                        )))
432                    }
433                } else {
434                    Err(ShimError::ExecutableNotFound(
435                        "No extracted executables found in archive configuration".to_string(),
436                    ))
437                }
438            }
439            SourceType::Url => {
440                // Check if we have a download_url (indicating this was originally from HTTP)
441                if let Some(ref download_url) = self.shim.download_url {
442                    // This shim was created from an HTTP URL
443                    let filename =
444                        crate::downloader::Downloader::extract_filename_from_url(download_url)
445                            .ok_or_else(|| {
446                                ShimError::Config(format!(
447                                    "Could not extract filename from download URL: {}",
448                                    download_url
449                                ))
450                            })?;
451
452                    // Try to find the downloaded file in the expected location
453                    // First try relative to home directory
454                    if let Some(home_dir) = dirs::home_dir() {
455                        let download_path = home_dir
456                            .join(".shimexe")
457                            .join(&self.shim.name)
458                            .join("bin")
459                            .join(&filename);
460
461                        if download_path.exists() {
462                            return Ok(download_path);
463                        }
464                    }
465
466                    // If not found, return an error indicating download is needed
467                    Err(ShimError::ExecutableNotFound(format!(
468                        "Executable not found for download URL: {}. Download may be required.",
469                        download_url
470                    )))
471                } else if crate::downloader::Downloader::is_url(&expanded_path) {
472                    // Legacy: path is still a URL (for backward compatibility)
473                    let filename =
474                        crate::downloader::Downloader::extract_filename_from_url(&expanded_path)
475                            .ok_or_else(|| {
476                                ShimError::Config(format!(
477                                    "Could not extract filename from URL: {}",
478                                    expanded_path
479                                ))
480                            })?;
481
482                    // Try to find the downloaded file in the expected location
483                    if let Some(home_dir) = dirs::home_dir() {
484                        let download_path = home_dir
485                            .join(".shimexe")
486                            .join(&self.shim.name)
487                            .join("bin")
488                            .join(&filename);
489
490                        if download_path.exists() {
491                            return Ok(download_path);
492                        }
493                    }
494
495                    // If not found, return an error indicating download is needed
496                    Err(ShimError::ExecutableNotFound(format!(
497                        "Executable not found for URL: {}. Download may be required.",
498                        expanded_path
499                    )))
500                } else {
501                    Err(ShimError::Config(
502                        "URL source type specified but no download URL found".to_string(),
503                    ))
504                }
505            }
506            SourceType::File => {
507                let path = PathBuf::from(expanded_path);
508
509                if path.is_absolute() {
510                    Ok(path)
511                } else {
512                    // Try to find in PATH
513                    which::which(&path)
514                        .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
515                }
516            }
517        }
518    }
519
520    /// Get the download URL for this shim (if it was created from HTTP)
521    pub fn get_download_url(&self) -> Option<&String> {
522        self.shim.download_url.as_ref()
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use std::io::Write;
530    use tempfile::NamedTempFile;
531
532    #[test]
533    fn test_shim_config_from_file() {
534        let mut temp_file = NamedTempFile::new().unwrap();
535        writeln!(
536            temp_file,
537            r#"
538[shim]
539name = "test"
540path = "echo"
541args = ["hello"]
542
543[env]
544TEST_VAR = "test_value"
545
546[metadata]
547description = "Test shim"
548version = "1.0.0"
549        "#
550        )
551        .unwrap();
552
553        let config = ShimConfig::from_file(temp_file.path()).unwrap();
554        assert_eq!(config.shim.name, "test");
555        assert_eq!(config.shim.path, "echo");
556        assert_eq!(config.shim.args, vec!["hello"]);
557        assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
558        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
559    }
560
561    #[test]
562    fn test_shim_config_basic_structure() {
563        let mut temp_file = NamedTempFile::new().unwrap();
564        writeln!(
565            temp_file,
566            r#"
567[shim]
568name = "test"
569path = "echo"
570
571[args]
572mode = "template"
573
574[metadata]
575description = "Test shim"
576        "#
577        )
578        .unwrap();
579
580        let config = ShimConfig::from_file(temp_file.path()).unwrap();
581        assert_eq!(config.shim.name, "test");
582        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
583        assert_eq!(config.metadata.description, Some("Test shim".to_string()));
584    }
585
586    #[test]
587    fn test_shim_config_validation() {
588        // Valid config
589        let config = ShimConfig {
590            shim: ShimCore {
591                name: "test".to_string(),
592                path: "echo".to_string(),
593                args: vec![],
594                cwd: None,
595                download_url: None,
596                source_type: SourceType::File,
597                extracted_executables: vec![],
598            },
599            args: Default::default(),
600            env: HashMap::new(),
601            metadata: Default::default(),
602            auto_update: None,
603        };
604        assert!(config.validate().is_ok());
605
606        // Invalid config - empty name
607        let invalid_config = ShimConfig {
608            shim: ShimCore {
609                name: "".to_string(),
610                path: "echo".to_string(),
611                args: vec![],
612                cwd: None,
613                download_url: None,
614                source_type: SourceType::File,
615                extracted_executables: vec![],
616            },
617            args: Default::default(),
618            env: HashMap::new(),
619            metadata: Default::default(),
620            auto_update: None,
621        };
622        assert!(invalid_config.validate().is_err());
623
624        // Invalid config - empty path
625        let invalid_config = ShimConfig {
626            shim: ShimCore {
627                name: "test".to_string(),
628                path: "".to_string(),
629                args: vec![],
630                cwd: None,
631                download_url: None,
632                source_type: SourceType::File,
633                extracted_executables: vec![],
634            },
635            args: Default::default(),
636            env: HashMap::new(),
637            metadata: Default::default(),
638            auto_update: None,
639        };
640        assert!(invalid_config.validate().is_err());
641    }
642
643    #[test]
644    fn test_shim_config_to_file() {
645        let config = ShimConfig {
646            shim: ShimCore {
647                name: "test".to_string(),
648                path: "echo".to_string(),
649                args: vec!["hello".to_string()],
650                cwd: None,
651                download_url: None,
652                source_type: SourceType::File,
653                extracted_executables: vec![],
654            },
655            args: Default::default(),
656            env: {
657                let mut env = HashMap::new();
658                env.insert("TEST_VAR".to_string(), "test_value".to_string());
659                env
660            },
661            metadata: ShimMetadata {
662                description: Some("Test shim".to_string()),
663                version: Some("1.0.0".to_string()),
664                author: None,
665                tags: vec![],
666            },
667            auto_update: None,
668        };
669
670        let temp_file = NamedTempFile::new().unwrap();
671        config.to_file(temp_file.path()).unwrap();
672
673        // Read back and verify
674        let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
675        assert_eq!(loaded_config.shim.name, config.shim.name);
676        assert_eq!(loaded_config.shim.path, config.shim.path);
677        assert_eq!(loaded_config.shim.args, config.shim.args);
678        assert_eq!(loaded_config.env, config.env);
679    }
680
681    #[test]
682    fn test_expand_env_vars() {
683        std::env::set_var("TEST_VAR", "test_value");
684
685        let mut config = ShimConfig {
686            shim: ShimCore {
687                name: "test".to_string(),
688                path: "${TEST_VAR}/bin/test".to_string(),
689                args: vec!["${TEST_VAR}".to_string()],
690                cwd: Some("${TEST_VAR}/work".to_string()),
691                download_url: None,
692                source_type: SourceType::File,
693                extracted_executables: vec![],
694            },
695            args: Default::default(),
696            env: {
697                let mut env = HashMap::new();
698                env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
699                env
700            },
701            metadata: Default::default(),
702            auto_update: None,
703        };
704
705        config.expand_env_vars().unwrap();
706
707        assert_eq!(config.shim.path, "test_value/bin/test");
708        assert_eq!(config.shim.args[0], "test_value");
709        assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
710        assert_eq!(
711            config.env.get("EXPANDED"),
712            Some(&"test_value_expanded".to_string())
713        );
714
715        std::env::remove_var("TEST_VAR");
716    }
717
718    #[test]
719    fn test_shim_config_with_args_template() {
720        let mut temp_file = NamedTempFile::new().unwrap();
721        write!(
722            temp_file,
723            r#"
724[shim]
725name = "test"
726path = "echo"
727
728[args]
729mode = "template"
730template = [
731    "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
732    "{{{{args('--version')}}}}"
733]
734
735[metadata]
736description = "Test shim with template args"
737        "#
738        )
739        .unwrap();
740
741        let config = ShimConfig::from_file(temp_file.path()).unwrap();
742        assert_eq!(config.shim.name, "test");
743        assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
744        assert!(config.args.template.is_some());
745
746        let template = config.args.template.unwrap();
747        assert_eq!(template.len(), 2);
748        assert_eq!(
749            template[0],
750            "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
751        );
752        assert_eq!(template[1], "{{args('--version')}}");
753    }
754
755    #[test]
756    fn test_shim_config_with_args_modes() {
757        // Test merge mode
758        let mut temp_file = NamedTempFile::new().unwrap();
759        writeln!(
760            temp_file,
761            r#"
762[shim]
763name = "test"
764path = "echo"
765
766[args]
767mode = "merge"
768default = ["--default"]
769prefix = ["--prefix"]
770suffix = ["--suffix"]
771        "#
772        )
773        .unwrap();
774
775        let config = ShimConfig::from_file(temp_file.path()).unwrap();
776        assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
777        assert_eq!(config.args.default, vec!["--default"]);
778        assert_eq!(config.args.prefix, vec!["--prefix"]);
779        assert_eq!(config.args.suffix, vec!["--suffix"]);
780    }
781
782    #[test]
783    fn test_shim_config_with_inline_template() {
784        let mut temp_file = NamedTempFile::new().unwrap();
785        write!(
786            temp_file,
787            r#"
788[shim]
789name = "test"
790path = "echo"
791
792[args]
793inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
794        "#
795        )
796        .unwrap();
797
798        let config = ShimConfig::from_file(temp_file.path()).unwrap();
799        assert!(config.args.inline.is_some());
800        assert_eq!(
801            config.args.inline.unwrap(),
802            "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
803        );
804    }
805
806    #[test]
807    fn test_shim_config_with_download_url() {
808        let mut temp_file = NamedTempFile::new().unwrap();
809        writeln!(
810            temp_file,
811            r#"
812[shim]
813name = "test-tool"
814path = "/home/user/.shimexe/test-tool/bin/test-tool.exe"
815download_url = "https://example.com/test-tool.exe"
816
817[metadata]
818description = "Test shim with download URL"
819        "#
820        )
821        .unwrap();
822
823        let config = ShimConfig::from_file(temp_file.path()).unwrap();
824        assert_eq!(config.shim.name, "test-tool");
825        assert_eq!(
826            config.shim.path,
827            "/home/user/.shimexe/test-tool/bin/test-tool.exe"
828        );
829        assert_eq!(
830            config.shim.download_url,
831            Some("https://example.com/test-tool.exe".to_string())
832        );
833        assert_eq!(
834            config.get_download_url(),
835            Some(&"https://example.com/test-tool.exe".to_string())
836        );
837    }
838
839    #[test]
840    fn test_shim_config_without_download_url() {
841        let mut temp_file = NamedTempFile::new().unwrap();
842        writeln!(
843            temp_file,
844            r#"
845[shim]
846name = "local-tool"
847path = "/usr/bin/local-tool"
848
849[metadata]
850description = "Test shim without download URL"
851        "#
852        )
853        .unwrap();
854
855        let config = ShimConfig::from_file(temp_file.path()).unwrap();
856        assert_eq!(config.shim.name, "local-tool");
857        assert_eq!(config.shim.path, "/usr/bin/local-tool");
858        assert_eq!(config.shim.download_url, None);
859        assert_eq!(config.get_download_url(), None);
860    }
861}