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}
153
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct ShimMetadata {
157 pub description: Option<String>,
159 pub version: Option<String>,
161 pub author: Option<String>,
163 #[serde(default)]
165 pub tags: Vec<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct AutoUpdate {
171 #[serde(default)]
173 pub enabled: bool,
174 pub provider: UpdateProvider,
176 pub download_url: String,
178 pub version_check: VersionCheck,
180 #[serde(default)]
182 pub check_interval_hours: u64,
183 pub pre_update_command: Option<String>,
185 pub post_update_command: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum UpdateProvider {
193 Github {
195 repo: String,
197 asset_pattern: String,
199 #[serde(default)]
201 include_prerelease: bool,
202 },
203 Https {
205 base_url: String,
207 version_url: Option<String>,
209 },
210 Custom {
212 update_command: String,
214 version_command: String,
216 },
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum VersionCheck {
223 GithubLatest {
225 repo: String,
227 #[serde(default)]
229 include_prerelease: bool,
230 },
231 Http {
233 url: String,
235 json_path: Option<String>,
237 regex_pattern: Option<String>,
239 },
240 Semver {
242 current: String,
244 check_url: String,
246 },
247 Command {
249 command: String,
251 args: Vec<String>,
253 },
254}
255
256impl ShimConfig {
257 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 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 pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
281 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(()); }
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 pub async fn to_file_async<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
297 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(()); }
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 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 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 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 pub fn expand_env_vars(&mut self) -> Result<()> {
356 self.shim.path = expand_env_vars(&self.shim.path)?;
358
359 for arg in &mut self.shim.args {
361 *arg = expand_env_vars(arg)?;
362 }
363
364 if let Some(ref mut cwd) = self.shim.cwd {
366 *cwd = expand_env_vars(cwd)?;
367 }
368
369 for value in self.env.values_mut() {
371 *value = expand_env_vars(value)?;
372 }
373
374 Ok(())
375 }
376
377 pub fn get_executable_path(&self) -> Result<PathBuf> {
379 let expanded_path = expand_env_vars(&self.shim.path)?;
380
381 if let Some(ref download_url) = self.shim.download_url {
383 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 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 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 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 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 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 which::which(&path)
447 .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
448 }
449 }
450 }
451
452 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 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 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 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 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 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}