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}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ShimMetadata {
46 pub description: Option<String>,
48 pub version: Option<String>,
50 pub author: Option<String>,
52 #[serde(default)]
54 pub tags: Vec<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AutoUpdate {
60 #[serde(default)]
62 pub enabled: bool,
63 pub provider: UpdateProvider,
65 pub download_url: String,
67 pub version_check: VersionCheck,
69 #[serde(default)]
71 pub check_interval_hours: u64,
72 pub pre_update_command: Option<String>,
74 pub post_update_command: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum UpdateProvider {
82 Github {
84 repo: String,
86 asset_pattern: String,
88 #[serde(default)]
90 include_prerelease: bool,
91 },
92 Https {
94 base_url: String,
96 version_url: Option<String>,
98 },
99 Custom {
101 update_command: String,
103 version_command: String,
105 },
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum VersionCheck {
112 GithubLatest {
114 repo: String,
116 #[serde(default)]
118 include_prerelease: bool,
119 },
120 Http {
122 url: String,
124 json_path: Option<String>,
126 regex_pattern: Option<String>,
128 },
129 Semver {
131 current: String,
133 check_url: String,
135 },
136 Command {
138 command: String,
140 args: Vec<String>,
142 },
143}
144
145impl ShimConfig {
146 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
148 let content = std::fs::read_to_string(&path).map_err(ShimError::Io)?;
149
150 let config: ShimConfig = toml::from_str(&content).map_err(ShimError::TomlParse)?;
151
152 config.validate()?;
153 Ok(config)
154 }
155
156 pub fn to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
158 let content = toml::to_string_pretty(self).map_err(ShimError::TomlSerialize)?;
159
160 std::fs::write(path, content).map_err(ShimError::Io)?;
161
162 Ok(())
163 }
164
165 pub fn validate(&self) -> Result<()> {
167 if self.shim.name.is_empty() {
168 return Err(ShimError::Config("Shim name cannot be empty".to_string()));
169 }
170
171 if self.shim.path.is_empty() {
172 return Err(ShimError::Config("Shim path cannot be empty".to_string()));
173 }
174
175 Ok(())
176 }
177
178 pub fn expand_env_vars(&mut self) -> Result<()> {
180 self.shim.path = expand_env_vars(&self.shim.path)?;
182
183 for arg in &mut self.shim.args {
185 *arg = expand_env_vars(arg)?;
186 }
187
188 if let Some(ref mut cwd) = self.shim.cwd {
190 *cwd = expand_env_vars(cwd)?;
191 }
192
193 for value in self.env.values_mut() {
195 *value = expand_env_vars(value)?;
196 }
197
198 Ok(())
199 }
200
201 pub fn get_executable_path(&self) -> Result<PathBuf> {
203 let expanded_path = expand_env_vars(&self.shim.path)?;
204 let path = PathBuf::from(expanded_path);
205
206 if path.is_absolute() {
207 Ok(path)
208 } else {
209 which::which(&path).map_err(|_| ShimError::ExecutableNotFound(self.shim.path.clone()))
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::io::Write;
219 use tempfile::NamedTempFile;
220
221 #[test]
222 fn test_shim_config_from_file() {
223 let mut temp_file = NamedTempFile::new().unwrap();
224 writeln!(
225 temp_file,
226 r#"
227[shim]
228name = "test"
229path = "echo"
230args = ["hello"]
231
232[env]
233TEST_VAR = "test_value"
234
235[metadata]
236description = "Test shim"
237version = "1.0.0"
238 "#
239 )
240 .unwrap();
241
242 let config = ShimConfig::from_file(temp_file.path()).unwrap();
243 assert_eq!(config.shim.name, "test");
244 assert_eq!(config.shim.path, "echo");
245 assert_eq!(config.shim.args, vec!["hello"]);
246 assert_eq!(config.env.get("TEST_VAR"), Some(&"test_value".to_string()));
247 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
248 }
249
250 #[test]
251 fn test_shim_config_basic_structure() {
252 let mut temp_file = NamedTempFile::new().unwrap();
253 writeln!(
254 temp_file,
255 r#"
256[shim]
257name = "test"
258path = "echo"
259
260[args]
261mode = "template"
262
263[metadata]
264description = "Test shim"
265 "#
266 )
267 .unwrap();
268
269 let config = ShimConfig::from_file(temp_file.path()).unwrap();
270 assert_eq!(config.shim.name, "test");
271 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
272 assert_eq!(config.metadata.description, Some("Test shim".to_string()));
273 }
274
275 #[test]
276 fn test_shim_config_validation() {
277 let config = ShimConfig {
279 shim: ShimCore {
280 name: "test".to_string(),
281 path: "echo".to_string(),
282 args: vec![],
283 cwd: None,
284 },
285 args: Default::default(),
286 env: HashMap::new(),
287 metadata: Default::default(),
288 auto_update: None,
289 };
290 assert!(config.validate().is_ok());
291
292 let invalid_config = ShimConfig {
294 shim: ShimCore {
295 name: "".to_string(),
296 path: "echo".to_string(),
297 args: vec![],
298 cwd: None,
299 },
300 args: Default::default(),
301 env: HashMap::new(),
302 metadata: Default::default(),
303 auto_update: None,
304 };
305 assert!(invalid_config.validate().is_err());
306
307 let invalid_config = ShimConfig {
309 shim: ShimCore {
310 name: "test".to_string(),
311 path: "".to_string(),
312 args: vec![],
313 cwd: None,
314 },
315 args: Default::default(),
316 env: HashMap::new(),
317 metadata: Default::default(),
318 auto_update: None,
319 };
320 assert!(invalid_config.validate().is_err());
321 }
322
323 #[test]
324 fn test_shim_config_to_file() {
325 let config = ShimConfig {
326 shim: ShimCore {
327 name: "test".to_string(),
328 path: "echo".to_string(),
329 args: vec!["hello".to_string()],
330 cwd: None,
331 },
332 args: Default::default(),
333 env: {
334 let mut env = HashMap::new();
335 env.insert("TEST_VAR".to_string(), "test_value".to_string());
336 env
337 },
338 metadata: ShimMetadata {
339 description: Some("Test shim".to_string()),
340 version: Some("1.0.0".to_string()),
341 author: None,
342 tags: vec![],
343 },
344 auto_update: None,
345 };
346
347 let temp_file = NamedTempFile::new().unwrap();
348 config.to_file(temp_file.path()).unwrap();
349
350 let loaded_config = ShimConfig::from_file(temp_file.path()).unwrap();
352 assert_eq!(loaded_config.shim.name, config.shim.name);
353 assert_eq!(loaded_config.shim.path, config.shim.path);
354 assert_eq!(loaded_config.shim.args, config.shim.args);
355 assert_eq!(loaded_config.env, config.env);
356 }
357
358 #[test]
359 fn test_expand_env_vars() {
360 std::env::set_var("TEST_VAR", "test_value");
361
362 let mut config = ShimConfig {
363 shim: ShimCore {
364 name: "test".to_string(),
365 path: "${TEST_VAR}/bin/test".to_string(),
366 args: vec!["${TEST_VAR}".to_string()],
367 cwd: Some("${TEST_VAR}/work".to_string()),
368 },
369 args: Default::default(),
370 env: {
371 let mut env = HashMap::new();
372 env.insert("EXPANDED".to_string(), "${TEST_VAR}_expanded".to_string());
373 env
374 },
375 metadata: Default::default(),
376 auto_update: None,
377 };
378
379 config.expand_env_vars().unwrap();
380
381 assert_eq!(config.shim.path, "test_value/bin/test");
382 assert_eq!(config.shim.args[0], "test_value");
383 assert_eq!(config.shim.cwd, Some("test_value/work".to_string()));
384 assert_eq!(
385 config.env.get("EXPANDED"),
386 Some(&"test_value_expanded".to_string())
387 );
388
389 std::env::remove_var("TEST_VAR");
390 }
391
392 #[test]
393 fn test_shim_config_with_args_template() {
394 let mut temp_file = NamedTempFile::new().unwrap();
395 write!(
396 temp_file,
397 r#"
398[shim]
399name = "test"
400path = "echo"
401
402[args]
403mode = "template"
404template = [
405 "{{{{if env('DEBUG') == 'true'}}}}--verbose{{{{endif}}}}",
406 "{{{{args('--version')}}}}"
407]
408
409[metadata]
410description = "Test shim with template args"
411 "#
412 )
413 .unwrap();
414
415 let config = ShimConfig::from_file(temp_file.path()).unwrap();
416 assert_eq!(config.shim.name, "test");
417 assert_eq!(config.args.mode, crate::template::ArgsMode::Template);
418 assert!(config.args.template.is_some());
419
420 let template = config.args.template.unwrap();
421 assert_eq!(template.len(), 2);
422 assert_eq!(
423 template[0],
424 "{{if env('DEBUG') == 'true'}}--verbose{{endif}}"
425 );
426 assert_eq!(template[1], "{{args('--version')}}");
427 }
428
429 #[test]
430 fn test_shim_config_with_args_modes() {
431 let mut temp_file = NamedTempFile::new().unwrap();
433 writeln!(
434 temp_file,
435 r#"
436[shim]
437name = "test"
438path = "echo"
439
440[args]
441mode = "merge"
442default = ["--default"]
443prefix = ["--prefix"]
444suffix = ["--suffix"]
445 "#
446 )
447 .unwrap();
448
449 let config = ShimConfig::from_file(temp_file.path()).unwrap();
450 assert_eq!(config.args.mode, crate::template::ArgsMode::Merge);
451 assert_eq!(config.args.default, vec!["--default"]);
452 assert_eq!(config.args.prefix, vec!["--prefix"]);
453 assert_eq!(config.args.suffix, vec!["--suffix"]);
454 }
455
456 #[test]
457 fn test_shim_config_with_inline_template() {
458 let mut temp_file = NamedTempFile::new().unwrap();
459 write!(
460 temp_file,
461 r#"
462[shim]
463name = "test"
464path = "echo"
465
466[args]
467inline = "{{{{env('CONFIG_PATH', '/default/config')}}}} {{{{args('--help')}}}}"
468 "#
469 )
470 .unwrap();
471
472 let config = ShimConfig::from_file(temp_file.path()).unwrap();
473 assert!(config.args.inline.is_some());
474 assert_eq!(
475 config.args.inline.unwrap(),
476 "{{env('CONFIG_PATH', '/default/config')}} {{args('--help')}}"
477 );
478 }
479}