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 new(name: impl Into<String>, path: impl Into<String>) -> Self {
292 Self {
293 shim: ShimCore {
294 name: name.into(),
295 path: path.into(),
296 args: Vec::new(),
297 cwd: None,
298 download_url: None,
299 source_type: SourceType::File,
300 extracted_executables: Vec::new(),
301 },
302 args: Default::default(),
303 env: HashMap::new(),
304 metadata: ShimMetadata::default(),
305 auto_update: None,
306 }
307 }
308
309 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
311 let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
312
313 let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
314
315 config.validate()?;
316 Ok(config)
317 }
318
319 pub async fn from_file_async<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
321 let content = tokio::fs::read_to_string(&path)
322 .await
323 .map_err(ShimError::Io)?;
324
325 let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
326
327 config.validate()?;
328 Ok(config)
329 }
330
331 pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
333 if let Ok(existing_content) = std::fs::read_to_string(&path) {
335 let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
336 if existing_content.trim() == new_content.trim() {
337 return Ok(()); }
339 }
340
341 let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
342 std::fs::write(path, content).map_err(ShimError::Io)?;
343
344 Ok(())
345 }
346
347 pub async fn to_file_async<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
349 if let Ok(existing_content) = tokio::fs::read_to_string(&path).await {
351 let new_content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
352 if existing_content.trim() == new_content.trim() {
353 return Ok(()); }
355 }
356
357 let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
358 tokio::fs::write(path, content)
359 .await
360 .map_err(ShimError::Io)?;
361
362 Ok(())
363 }
364
365 pub async fn from_files_concurrent<P: AsRef<std::path::Path>>(
367 paths: Vec<P>,
368 ) -> Vec<Result<Self>> {
369 use futures_util::future::join_all;
370
371 let futures = paths
372 .into_iter()
373 .map(|path| Self::from_file_async(path))
374 .collect::<Vec<_>>();
375
376 join_all(futures).await
377 }
378
379 pub async fn to_files_concurrent<P: AsRef<std::path::Path>>(
381 configs_and_paths: Vec<(&Self, P)>,
382 ) -> Vec<Result<()>> {
383 use futures_util::future::join_all;
384
385 let futures = configs_and_paths
386 .into_iter()
387 .map(|(config, path)| config.to_file_async(path))
388 .collect::<Vec<_>>();
389
390 join_all(futures).await
391 }
392
393 pub fn validate(&self) -> Result<()> {
395 if self.shim.name.is_empty() {
396 return Err(ShimError::Config("Shim name cannot be empty".to_string()));
397 }
398
399 if self.shim.path.is_empty() {
400 return Err(ShimError::Config("Shim path cannot be empty".to_string()));
401 }
402
403 Ok(())
404 }
405
406 pub fn expand_env_vars(&mut self) -> Result<()> {
408 self.shim.path = expand_env_vars(&self.shim.path)?;
410
411 for arg in &mut self.shim.args {
413 *arg = expand_env_vars(arg)?;
414 }
415
416 if let Some(ref mut cwd) = self.shim.cwd {
418 *cwd = expand_env_vars(cwd)?;
419 }
420
421 for value in self.env.values_mut() {
423 *value = expand_env_vars(value)?;
424 }
425
426 Ok(())
427 }
428
429 pub fn get_executable_path(&self) -> Result<PathBuf> {
431 let expanded_path = expand_env_vars(&self.shim.path)?;
432
433 match self.shim.source_type {
434 SourceType::Archive => {
435 if let Some(primary_exe) = self
437 .shim
438 .extracted_executables
439 .iter()
440 .find(|exe| exe.is_primary)
441 .or_else(|| self.shim.extracted_executables.first())
442 {
443 let path = PathBuf::from(&primary_exe.full_path);
444 if path.exists() {
445 Ok(path)
446 } else {
447 Err(ShimError::ExecutableNotFound(format!(
448 "Extracted executable not found: {}. Re-extraction may be required.",
449 primary_exe.full_path
450 )))
451 }
452 } else {
453 Err(ShimError::ExecutableNotFound(
454 "No extracted executables found in archive configuration".to_string(),
455 ))
456 }
457 }
458 SourceType::Url => {
459 if let Some(ref download_url) = self.shim.download_url {
461 let filename =
463 crate::downloader::Downloader::extract_filename_from_url(download_url)
464 .ok_or_else(|| {
465 ShimError::Config(format!(
466 "Could not extract filename from download URL: {}",
467 download_url
468 ))
469 })?;
470
471 if let Some(home_dir) = dirs::home_dir() {
474 let download_path = home_dir
475 .join(".shimexe")
476 .join(&self.shim.name)
477 .join("bin")
478 .join(&filename);
479
480 if download_path.exists() {
481 return Ok(download_path);
482 }
483 }
484
485 Err(ShimError::ExecutableNotFound(format!(
487 "Executable not found for download URL: {}. Download may be required.",
488 download_url
489 )))
490 } else if crate::downloader::Downloader::is_url(&expanded_path) {
491 let filename =
493 crate::downloader::Downloader::extract_filename_from_url(&expanded_path)
494 .ok_or_else(|| {
495 ShimError::Config(format!(
496 "Could not extract filename from URL: {}",
497 expanded_path
498 ))
499 })?;
500
501 if let Some(home_dir) = dirs::home_dir() {
503 let download_path = home_dir
504 .join(".shimexe")
505 .join(&self.shim.name)
506 .join("bin")
507 .join(&filename);
508
509 if download_path.exists() {
510 return Ok(download_path);
511 }
512 }
513
514 Err(ShimError::ExecutableNotFound(format!(
516 "Executable not found for URL: {}. Download may be required.",
517 expanded_path
518 )))
519 } else {
520 Err(ShimError::Config(
521 "URL source type specified but no download URL found".to_string(),
522 ))
523 }
524 }
525 SourceType::File => {
526 let path = PathBuf::from(expanded_path);
527
528 if path.is_absolute() {
529 Ok(path)
530 } else {
531 which::which(&path)
533 .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
534 }
535 }
536 }
537 }
538
539 pub fn get_download_url(&self) -> Option<&String> {
541 self.shim.download_url.as_ref()
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use std::io::Write;
549 use tempfile::NamedTempFile;
550
551 #[test]
552 fn test_shim_config_from_file() {
553 let mut temp_file = NamedTempFile::new().unwrap();
554 writeln!(
555 temp_file,
556 r#"
557[shim]
558name = "test"
559path = "echo"
560args = ["hello"]
561
562[env]
563TEST_VAR = "test_value"
564
565[metadata]
566description = "Test shim"
567version = "1.0.0"
568 "#
569 )
570 .unwrap();
571
572 let config = ShimConfig::from_file(temp_file.path()).unwrap();
573 assert_eq!(config.shim.name, "test");
574 assert_eq!(config.shim.path, "echo");
575 assert_eq!(config.shim.args, vec!["hello"]);
576 assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
577 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
578 }
579
580 #[test]
581 fn test_shim_config_basic_structure() {
582 let mut temp_file = NamedTempFile::new().unwrap();
583 writeln!(
584 temp_file,
585 r#"
586[shim]
587name = "test"
588path = "echo"
589
590[args]
591mode = "template"
592
593[metadata]
594description = "Test shim"
595 "#
596 )
597 .unwrap();
598
599 let config = ShimConfig::from_file(temp_file.path()).unwrap();
600 assert_eq!(config.shim.name, "test");
601 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
602 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
603 }
604
605 #[test]
606 fn test_shim_config_validation() {
607 let config = ShimConfig {
609 shim: ShimCore {
610 name: "test".to_string(),
611 path: "echo".to_string(),
612 args: vec![],
613 cwd: None,
614 download_url: None,
615 source_type: SourceType::File,
616 extracted_executables: vec![],
617 },
618 args: Default::default(),
619 env: HashMap::new(),
620 metadata: Default::default(),
621 auto_update: None,
622 };
623 assert!(config.validate().is_ok());
624
625 let invalid_config = ShimConfig {
627 shim: ShimCore {
628 name: "".to_string(),
629 path: "echo".to_string(),
630 args: vec![],
631 cwd: None,
632 download_url: None,
633 source_type: SourceType::File,
634 extracted_executables: vec![],
635 },
636 args: Default::default(),
637 env: HashMap::new(),
638 metadata: Default::default(),
639 auto_update: None,
640 };
641 assert!(invalid_config.validate().is_err());
642
643 let invalid_config = ShimConfig {
645 shim: ShimCore {
646 name: "test".to_string(),
647 path: "".to_string(),
648 args: vec![],
649 cwd: None,
650 download_url: None,
651 source_type: SourceType::File,
652 extracted_executables: vec![],
653 },
654 args: Default::default(),
655 env: HashMap::new(),
656 metadata: Default::default(),
657 auto_update: None,
658 };
659 assert!(invalid_config.validate().is_err());
660 }
661
662 #[test]
663 fn test_shim_config_to_file() {
664 let config = ShimConfig {
665 shim: ShimCore {
666 name: "test".to_string(),
667 path: "echo".to_string(),
668 args: vec!["hello".to_string()],
669 cwd: None,
670 download_url: None,
671 source_type: SourceType::File,
672 extracted_executables: vec![],
673 },
674 args: Default::default(),
675 env: {
676 let mut env = HashMap::new();
677 env.insert("TEST_VAR".to_string(), "test_value".to_string());
678 env
679 },
680 metadata: ShimMetadata {
681 description: Some("Test shim".to_string()),
682 version: Some("1.0.0".to_string()),
683 author: None,
684 tags: vec![],
685 },
686 auto_update: None,
687 };
688
689 let temp_file = NamedTempFile::new().unwrap();
690 config.to_file(temp_file.path()).unwrap();
691
692 let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
694 assert_eq!(loaded_config.shim.name, config.shim.name);
695 assert_eq!(loaded_config.shim.path, config.shim.path);
696 assert_eq!(loaded_config.shim.args, config.shim.args);
697 assert_eq!(loaded_config.env, config.env);
698 }
699
700 #[test]
701 fn test_expand_env_vars() {
702 std::env::set_var("TEST_VAR", "test_value");
703
704 let mut config = ShimConfig {
705 shim: ShimCore {
706 name: "test".to_string(),
707 path: "${TEST_VAR}/bin/test".to_string(),
708 args: vec!["${TEST_VAR}".to_string()],
709 cwd: Some("${TEST_VAR}/work".to_string()),
710 download_url: None,
711 source_type: SourceType::File,
712 extracted_executables: vec![],
713 },
714 args: Default::default(),
715 env: {
716 let mut env = HashMap::new();
717 env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
718 env
719 },
720 metadata: Default::default(),
721 auto_update: None,
722 };
723
724 config.expand_env_vars().unwrap();
725
726 assert_eq!(config.shim.path, "test_value/bin/test");
727 assert_eq!(config.shim.args[0], "test_value");
728 assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
729 assert_eq!(
730 config.env.get("EXPANDED"),
731 Some(&"test_value_expanded".to_string())
732 );
733
734 std::env::remove_var("TEST_VAR");
735 }
736
737 #[test]
738 fn test_shim_config_with_args_template() {
739 let mut temp_file = NamedTempFile::new().unwrap();
740 write!(
741 temp_file,
742 r#"
743[shim]
744name = "test"
745path = "echo"
746
747[args]
748mode = "template"
749template = [
750 "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
751 "{{{{args('--version')}}}}"
752]
753
754[metadata]
755description = "Test shim with template args"
756 "#
757 )
758 .unwrap();
759
760 let config = ShimConfig::from_file(temp_file.path()).unwrap();
761 assert_eq!(config.shim.name, "test");
762 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
763 assert!(config.args.template.is_some());
764
765 let template = config.args.template.unwrap();
766 assert_eq!(template.len(), 2);
767 assert_eq!(
768 template[0],
769 "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
770 );
771 assert_eq!(template[1], "{{args('--version')}}");
772 }
773
774 #[test]
775 fn test_shim_config_with_args_modes() {
776 let mut temp_file = NamedTempFile::new().unwrap();
778 writeln!(
779 temp_file,
780 r#"
781[shim]
782name = "test"
783path = "echo"
784
785[args]
786mode = "merge"
787default = ["--default"]
788prefix = ["--prefix"]
789suffix = ["--suffix"]
790 "#
791 )
792 .unwrap();
793
794 let config = ShimConfig::from_file(temp_file.path()).unwrap();
795 assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
796 assert_eq!(config.args.default, vec!["--default"]);
797 assert_eq!(config.args.prefix, vec!["--prefix"]);
798 assert_eq!(config.args.suffix, vec!["--suffix"]);
799 }
800
801 #[test]
802 fn test_shim_config_with_inline_template() {
803 let mut temp_file = NamedTempFile::new().unwrap();
804 write!(
805 temp_file,
806 r#"
807[shim]
808name = "test"
809path = "echo"
810
811[args]
812inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
813 "#
814 )
815 .unwrap();
816
817 let config = ShimConfig::from_file(temp_file.path()).unwrap();
818 assert!(config.args.inline.is_some());
819 assert_eq!(
820 config.args.inline.unwrap(),
821 "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
822 );
823 }
824
825 #[test]
826 fn test_shim_config_with_download_url() {
827 let mut temp_file = NamedTempFile::new().unwrap();
828 writeln!(
829 temp_file,
830 r#"
831[shim]
832name = "test-tool"
833path = "/home/user/.shimexe/test-tool/bin/test-tool.exe"
834download_url = "https://example.com/test-tool.exe"
835
836[metadata]
837description = "Test shim with download URL"
838 "#
839 )
840 .unwrap();
841
842 let config = ShimConfig::from_file(temp_file.path()).unwrap();
843 assert_eq!(config.shim.name, "test-tool");
844 assert_eq!(
845 config.shim.path,
846 "/home/user/.shimexe/test-tool/bin/test-tool.exe"
847 );
848 assert_eq!(
849 config.shim.download_url,
850 Some("https://example.com/test-tool.exe".to_string())
851 );
852 assert_eq!(
853 config.get_download_url(),
854 Some(&"https://example.com/test-tool.exe".to_string())
855 );
856 }
857
858 #[test]
859 fn test_shim_config_without_download_url() {
860 let mut temp_file = NamedTempFile::new().unwrap();
861 writeln!(
862 temp_file,
863 r#"
864[shim]
865name = "local-tool"
866path = "/usr/bin/local-tool"
867
868[metadata]
869description = "Test shim without download URL"
870 "#
871 )
872 .unwrap();
873
874 let config = ShimConfig::from_file(temp_file.path()).unwrap();
875 assert_eq!(config.shim.name, "local-tool");
876 assert_eq!(config.shim.path, "/usr/bin/local-tool");
877 assert_eq!(config.shim.download_url, None);
878 assert_eq!(config.get_download_url(), None);
879 }
880}