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#[derive(Debug, Clone)]
13struct CacheEntry {
14 config: ShimConfig,
15 last_modified: SystemTime,
16 cached_at: SystemTime,
17}
18
19#[derive(Debug, Clone)]
21pub struct ConfigCache {
22 cache: Arc<Mutex<HashMap<PathBuf, CacheEntry>>>,
23 ttl: Duration,
24}
25
26impl ConfigCache {
27 pub fn new(ttl: Duration) -> Self {
29 Self {
30 cache: Arc::new(Mutex::new(HashMap::new())),
31 ttl,
32 }
33 }
34
35 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 if let Ok(cache) = self.cache.lock() {
42 if let Some(entry) = cache.get(&path) {
43 if now.duration_since(entry.cached_at).unwrap_or(Duration::MAX) < self.ttl {
45 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 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 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 pub fn clear(&self) {
87 if let Ok(mut cache) = self.cache.lock() {
88 cache.clear();
89 }
90 }
91
92 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 fn default() -> Self {
113 Self::new(Duration::from_secs(300))
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ShimConfig {
120 pub shim: ShimCore,
122 #[serde(default)]
124 pub args: ArgsConfig,
125 #[serde(default)]
127 pub env: HashMap<String, String>,
128 #[serde(default)]
130 pub metadata: ShimMetadata,
131 #[serde(default)]
133 pub auto_update: Option<AutoUpdate>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ShimCore {
139 pub name: String,
141 pub path: String,
143 #[serde(default)]
145 pub args: Vec<String>,
146 #[serde(default)]
148 pub cwd: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub download_url: Option<String>,
152 #[serde(default)]
154 pub source_type: SourceType,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub extracted_executables: Vec<ExtractedExecutable>,
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "lowercase")]
163pub enum SourceType {
164 #[default]
166 File,
167 Archive,
169 Url,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ExtractedExecutable {
176 pub name: String,
178 pub path: String,
180 pub full_path: String,
182 #[serde(default)]
184 pub is_primary: bool,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct ShimMetadata {
190 pub description: Option<String>,
192 pub version: Option<String>,
194 pub author: Option<String>,
196 #[serde(default)]
198 pub tags: Vec<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct AutoUpdate {
204 #[serde(default)]
206 pub enabled: bool,
207 pub provider: UpdateProvider,
209 pub download_url: String,
211 pub version_check: VersionCheck,
213 #[serde(default)]
215 pub check_interval_hours: u64,
216 pub pre_update_command: Option<String>,
218 pub post_update_command: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum UpdateProvider {
226 Github {
228 repo: String,
230 asset_pattern: String,
232 #[serde(default)]
234 include_prerelease: bool,
235 },
236 Https {
238 base_url: String,
240 version_url: Option<String>,
242 },
243 Custom {
245 update_command: String,
247 version_command: String,
249 },
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum VersionCheck {
256 GithubLatest {
258 repo: String,
260 #[serde(default)]
262 include_prerelease: bool,
263 },
264 Http {
266 url: String,
268 json_path: Option<String>,
270 regex_pattern: Option<String>,
272 },
273 Semver {
275 current: String,
277 check_url: String,
279 },
280 Command {
282 command: String,
284 args: Vec<String>,
286 },
287}
288
289impl ShimConfig {
290 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 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 pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
314 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(()); }
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 pub async fn to_file_async<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
330 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(()); }
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 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 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 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 pub fn expand_env_vars(&mut self) -> Result<()> {
389 self.shim.path = expand_env_vars(&self.shim.path)?;
391
392 for arg in &mut self.shim.args {
394 *arg = expand_env_vars(arg)?;
395 }
396
397 if let Some(ref mut cwd) = self.shim.cwd {
399 *cwd = expand_env_vars(cwd)?;
400 }
401
402 for value in self.env.values_mut() {
404 *value = expand_env_vars(value)?;
405 }
406
407 Ok(())
408 }
409
410 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 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 if let Some(ref download_url) = self.shim.download_url {
442 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 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 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 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 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 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 which::which(&path)
514 .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
515 }
516 }
517 }
518 }
519
520 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 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 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 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 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 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}