1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use crate::error::{Result, ShimError};
6use crate::template::ArgsConfig;
7use crate::utils::expand_env_vars;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ShimConfig {
12 pub shim: ShimCore,
14 #[serde(default)]
16 pub args: ArgsConfig,
17 #[serde(default)]
19 pub env: HashMap<String, String>,
20 #[serde(default)]
22 pub metadata: ShimMetadata,
23 #[serde(default)]
25 pub auto_update: Option<AutoUpdate>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ShimCore {
31 pub name: String,
33 pub path: String,
35 #[serde(default)]
37 pub args: Vec<String>,
38 #[serde(default)]
40 pub cwd: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub download_url: Option<String>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct ShimMetadata {
49 pub description: Option<String>,
51 pub version: Option<String>,
53 pub author: Option<String>,
55 #[serde(default)]
57 pub tags: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AutoUpdate {
63 #[serde(default)]
65 pub enabled: bool,
66 pub provider: UpdateProvider,
68 pub download_url: String,
70 pub version_check: VersionCheck,
72 #[serde(default)]
74 pub check_interval_hours: u64,
75 pub pre_update_command: Option<String>,
77 pub post_update_command: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum UpdateProvider {
85 Github {
87 repo: String,
89 asset_pattern: String,
91 #[serde(default)]
93 include_prerelease: bool,
94 },
95 Https {
97 base_url: String,
99 version_url: Option<String>,
101 },
102 Custom {
104 update_command: String,
106 version_command: String,
108 },
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum VersionCheck {
115 GithubLatest {
117 repo: String,
119 #[serde(default)]
121 include_prerelease: bool,
122 },
123 Http {
125 url: String,
127 json_path: Option<String>,
129 regex_pattern: Option<String>,
131 },
132 Semver {
134 current: String,
136 check_url: String,
138 },
139 Command {
141 command: String,
143 args: Vec<String>,
145 },
146}
147
148impl ShimConfig {
149 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
151 let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
152
153 let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
154
155 config.validate()?;
156 Ok(config)
157 }
158
159 pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
161 let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
162
163 std::fs::write(path, content).map_err(ShimError::Io)?;
164
165 Ok(())
166 }
167
168 pub fn validate(&self) -> Result<()> {
170 if self.shim.name.is_empty() {
171 return Err(ShimError::Config("Shim name cannot be empty".to_string()));
172 }
173
174 if self.shim.path.is_empty() {
175 return Err(ShimError::Config("Shim path cannot be empty".to_string()));
176 }
177
178 Ok(())
179 }
180
181 pub fn expand_env_vars(&mut self) -> Result<()> {
183 self.shim.path = expand_env_vars(&self.shim.path)?;
185
186 for arg in &mut self.shim.args {
188 *arg = expand_env_vars(arg)?;
189 }
190
191 if let Some(ref mut cwd) = self.shim.cwd {
193 *cwd = expand_env_vars(cwd)?;
194 }
195
196 for value in self.env.values_mut() {
198 *value = expand_env_vars(value)?;
199 }
200
201 Ok(())
202 }
203
204 pub fn get_executable_path(&self) -> Result<PathBuf> {
206 let expanded_path = expand_env_vars(&self.shim.path)?;
207
208 if let Some(ref download_url) = self.shim.download_url {
210 let filename = crate::downloader::Downloader::extract_filename_from_url(download_url)
212 .ok_or_else(|| {
213 ShimError::Config(format!(
214 "Could not extract filename from download URL: {}",
215 download_url
216 ))
217 })?;
218
219 if let Some(home_dir) = dirs::home_dir() {
222 let download_path = home_dir
223 .join(".shimexe")
224 .join(&self.shim.name)
225 .join("bin")
226 .join(&filename);
227
228 if download_path.exists() {
229 return Ok(download_path);
230 }
231 }
232
233 Err(ShimError::ExecutableNotFound(format!(
235 "Executable not found for download URL: {}. Download may be required.",
236 download_url
237 )))
238 } else if crate::downloader::Downloader::is_url(&expanded_path) {
239 let filename = crate::downloader::Downloader::extract_filename_from_url(&expanded_path)
241 .ok_or_else(|| {
242 ShimError::Config(format!(
243 "Could not extract filename from URL: {}",
244 expanded_path
245 ))
246 })?;
247
248 if let Some(home_dir) = dirs::home_dir() {
250 let download_path = home_dir
251 .join(".shimexe")
252 .join(&self.shim.name)
253 .join("bin")
254 .join(&filename);
255
256 if download_path.exists() {
257 return Ok(download_path);
258 }
259 }
260
261 Err(ShimError::ExecutableNotFound(format!(
263 "Executable not found for URL: {}. Download may be required.",
264 expanded_path
265 )))
266 } else {
267 let path = PathBuf::from(expanded_path);
268
269 if path.is_absolute() {
270 Ok(path)
271 } else {
272 which::which(&path)
274 .map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
275 }
276 }
277 }
278
279 pub fn get_download_url(&self) -> Option<&String> {
281 self.shim.download_url.as_ref()
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::io::Write;
289 use tempfile::NamedTempFile;
290
291 #[test]
292 fn test_shim_config_from_file() {
293 let mut temp_file = NamedTempFile::new().unwrap();
294 writeln!(
295 temp_file,
296 r#"
297[shim]
298name = "test"
299path = "echo"
300args = ["hello"]
301
302[env]
303TEST_VAR = "test_value"
304
305[metadata]
306description = "Test shim"
307version = "1.0.0"
308 "#
309 )
310 .unwrap();
311
312 let config = ShimConfig::from_file(temp_file.path()).unwrap();
313 assert_eq!(config.shim.name, "test");
314 assert_eq!(config.shim.path, "echo");
315 assert_eq!(config.shim.args, vec!["hello"]);
316 assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
317 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
318 }
319
320 #[test]
321 fn test_shim_config_basic_structure() {
322 let mut temp_file = NamedTempFile::new().unwrap();
323 writeln!(
324 temp_file,
325 r#"
326[shim]
327name = "test"
328path = "echo"
329
330[args]
331mode = "template"
332
333[metadata]
334description = "Test shim"
335 "#
336 )
337 .unwrap();
338
339 let config = ShimConfig::from_file(temp_file.path()).unwrap();
340 assert_eq!(config.shim.name, "test");
341 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
342 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
343 }
344
345 #[test]
346 fn test_shim_config_validation() {
347 let config = ShimConfig {
349 shim: ShimCore {
350 name: "test".to_string(),
351 path: "echo".to_string(),
352 args: vec![],
353 cwd: None,
354 download_url: None,
355 },
356 args: Default::default(),
357 env: HashMap::new(),
358 metadata: Default::default(),
359 auto_update: None,
360 };
361 assert!(config.validate().is_ok());
362
363 let invalid_config = ShimConfig {
365 shim: ShimCore {
366 name: "".to_string(),
367 path: "echo".to_string(),
368 args: vec![],
369 cwd: None,
370 download_url: None,
371 },
372 args: Default::default(),
373 env: HashMap::new(),
374 metadata: Default::default(),
375 auto_update: None,
376 };
377 assert!(invalid_config.validate().is_err());
378
379 let invalid_config = ShimConfig {
381 shim: ShimCore {
382 name: "test".to_string(),
383 path: "".to_string(),
384 args: vec![],
385 cwd: None,
386 download_url: None,
387 },
388 args: Default::default(),
389 env: HashMap::new(),
390 metadata: Default::default(),
391 auto_update: None,
392 };
393 assert!(invalid_config.validate().is_err());
394 }
395
396 #[test]
397 fn test_shim_config_to_file() {
398 let config = ShimConfig {
399 shim: ShimCore {
400 name: "test".to_string(),
401 path: "echo".to_string(),
402 args: vec!["hello".to_string()],
403 cwd: None,
404 download_url: None,
405 },
406 args: Default::default(),
407 env: {
408 let mut env = HashMap::new();
409 env.insert("TEST_VAR".to_string(), "test_value".to_string());
410 env
411 },
412 metadata: ShimMetadata {
413 description: Some("Test shim".to_string()),
414 version: Some("1.0.0".to_string()),
415 author: None,
416 tags: vec![],
417 },
418 auto_update: None,
419 };
420
421 let temp_file = NamedTempFile::new().unwrap();
422 config.to_file(temp_file.path()).unwrap();
423
424 let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
426 assert_eq!(loaded_config.shim.name, config.shim.name);
427 assert_eq!(loaded_config.shim.path, config.shim.path);
428 assert_eq!(loaded_config.shim.args, config.shim.args);
429 assert_eq!(loaded_config.env, config.env);
430 }
431
432 #[test]
433 fn test_expand_env_vars() {
434 std::env::set_var("TEST_VAR", "test_value");
435
436 let mut config = ShimConfig {
437 shim: ShimCore {
438 name: "test".to_string(),
439 path: "${TEST_VAR}/bin/test".to_string(),
440 args: vec!["${TEST_VAR}".to_string()],
441 cwd: Some("${TEST_VAR}/work".to_string()),
442 download_url: None,
443 },
444 args: Default::default(),
445 env: {
446 let mut env = HashMap::new();
447 env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
448 env
449 },
450 metadata: Default::default(),
451 auto_update: None,
452 };
453
454 config.expand_env_vars().unwrap();
455
456 assert_eq!(config.shim.path, "test_value/bin/test");
457 assert_eq!(config.shim.args[0], "test_value");
458 assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
459 assert_eq!(
460 config.env.get("EXPANDED"),
461 Some(&"test_value_expanded".to_string())
462 );
463
464 std::env::remove_var("TEST_VAR");
465 }
466
467 #[test]
468 fn test_shim_config_with_args_template() {
469 let mut temp_file = NamedTempFile::new().unwrap();
470 write!(
471 temp_file,
472 r#"
473[shim]
474name = "test"
475path = "echo"
476
477[args]
478mode = "template"
479template = [
480 "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
481 "{{{{args('--version')}}}}"
482]
483
484[metadata]
485description = "Test shim with template args"
486 "#
487 )
488 .unwrap();
489
490 let config = ShimConfig::from_file(temp_file.path()).unwrap();
491 assert_eq!(config.shim.name, "test");
492 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
493 assert!(config.args.template.is_some());
494
495 let template = config.args.template.unwrap();
496 assert_eq!(template.len(), 2);
497 assert_eq!(
498 template[0],
499 "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
500 );
501 assert_eq!(template[1], "{{args('--version')}}");
502 }
503
504 #[test]
505 fn test_shim_config_with_args_modes() {
506 let mut temp_file = NamedTempFile::new().unwrap();
508 writeln!(
509 temp_file,
510 r#"
511[shim]
512name = "test"
513path = "echo"
514
515[args]
516mode = "merge"
517default = ["--default"]
518prefix = ["--prefix"]
519suffix = ["--suffix"]
520 "#
521 )
522 .unwrap();
523
524 let config = ShimConfig::from_file(temp_file.path()).unwrap();
525 assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
526 assert_eq!(config.args.default, vec!["--default"]);
527 assert_eq!(config.args.prefix, vec!["--prefix"]);
528 assert_eq!(config.args.suffix, vec!["--suffix"]);
529 }
530
531 #[test]
532 fn test_shim_config_with_inline_template() {
533 let mut temp_file = NamedTempFile::new().unwrap();
534 write!(
535 temp_file,
536 r#"
537[shim]
538name = "test"
539path = "echo"
540
541[args]
542inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
543 "#
544 )
545 .unwrap();
546
547 let config = ShimConfig::from_file(temp_file.path()).unwrap();
548 assert!(config.args.inline.is_some());
549 assert_eq!(
550 config.args.inline.unwrap(),
551 "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
552 );
553 }
554
555 #[test]
556 fn test_shim_config_with_download_url() {
557 let mut temp_file = NamedTempFile::new().unwrap();
558 writeln!(
559 temp_file,
560 r#"
561[shim]
562name = "test-tool"
563path = "/home/user/.shimexe/test-tool/bin/test-tool.exe"
564download_url = "https://example.com/test-tool.exe"
565
566[metadata]
567description = "Test shim with download URL"
568 "#
569 )
570 .unwrap();
571
572 let config = ShimConfig::from_file(temp_file.path()).unwrap();
573 assert_eq!(config.shim.name, "test-tool");
574 assert_eq!(
575 config.shim.path,
576 "/home/user/.shimexe/test-tool/bin/test-tool.exe"
577 );
578 assert_eq!(
579 config.shim.download_url,
580 Some("https://example.com/test-tool.exe".to_string())
581 );
582 assert_eq!(
583 config.get_download_url(),
584 Some(&"https://example.com/test-tool.exe".to_string())
585 );
586 }
587
588 #[test]
589 fn test_shim_config_without_download_url() {
590 let mut temp_file = NamedTempFile::new().unwrap();
591 writeln!(
592 temp_file,
593 r#"
594[shim]
595name = "local-tool"
596path = "/usr/bin/local-tool"
597
598[metadata]
599description = "Test shim without download URL"
600 "#
601 )
602 .unwrap();
603
604 let config = ShimConfig::from_file(temp_file.path()).unwrap();
605 assert_eq!(config.shim.name, "local-tool");
606 assert_eq!(config.shim.path, "/usr/bin/local-tool");
607 assert_eq!(config.shim.download_url, None);
608 assert_eq!(config.get_download_url(), None);
609 }
610}