use anyhow::{Context, Result};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
fn deserialize_args<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct ArgsVisitor;
impl<'de> Visitor<'de> for ArgsVisitor {
type Value = Option<Vec<String>>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or array of strings")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(ArgsValueVisitor).map(Some)
}
}
struct ArgsValueVisitor;
impl<'de> Visitor<'de> for ArgsValueVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or array of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
shell_words::split(value).map_err(|_| de::Error::custom("invalid shell arguments"))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut vec = Vec::new();
while let Some(element) = seq.next_element::<String>()? {
vec.push(element);
}
Ok(vec)
}
}
deserializer.deserialize_option(ArgsVisitor)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShimConfig {
pub path: String,
#[serde(deserialize_with = "deserialize_args", default)]
pub args: Option<Vec<String>>,
pub working_dir: Option<String>,
pub env: Option<HashMap<String, String>>,
pub hide_console: Option<bool>,
pub run_as_admin: Option<bool>,
pub signal_handling: Option<SignalHandling>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalHandling {
pub ignore_sigint: Option<bool>,
pub forward_signals: Option<bool>,
pub kill_on_exit: Option<bool>,
}
impl Default for SignalHandling {
fn default() -> Self {
Self {
ignore_sigint: Some(true),
forward_signals: Some(true),
kill_on_exit: Some(true),
}
}
}
impl ShimConfig {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read shim file: {}", path.as_ref().display()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self> {
toml::from_str::<ShimConfig>(content)
.with_context(|| "Failed to parse shim configuration as TOML")
}
pub fn resolved_path(&self) -> String {
expand_env_vars(&self.path)
}
pub fn resolved_args(&self) -> Vec<String> {
if let Some(ref args) = self.args {
args.iter().map(|arg| expand_env_vars(arg)).collect()
} else {
Vec::new()
}
}
pub fn resolved_working_dir(&self) -> Option<String> {
self.working_dir.as_ref().map(|dir| expand_env_vars(dir))
}
pub fn resolved_env(&self) -> HashMap<String, String> {
self.env
.as_ref()
.map(|env| {
env.iter()
.map(|(k, v)| (k.clone(), expand_env_vars(v)))
.collect()
})
.unwrap_or_default()
}
}
fn expand_env_vars(input: &str) -> String {
let mut result = input.to_string();
while let Some(start) = result.find('$') {
let (var_start, var_end, var_name) = if result.chars().nth(start + 1) == Some('{') {
if let Some(end) = result[start + 2..].find('}') {
(start, start + end + 3, &result[start + 2..start + end + 2])
} else {
break;
}
} else {
let var_start_pos = start + 1;
let remaining = &result[var_start_pos..];
let var_len = remaining
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.count();
if var_len == 0 {
result.replace_range(start..start + 1, "");
continue;
}
let var_end_pos = var_start_pos + var_len;
let var_name = &result[var_start_pos..var_end_pos];
let actual_var_name = if std::env::var(var_name).is_err() {
let parts: Vec<&str> = var_name.split('_').collect();
let mut found_var = None;
for i in (1..=parts.len()).rev() {
let candidate = parts[..i].join("_");
if std::env::var(&candidate).is_ok() {
found_var = Some((candidate, parts[..i].join("_").len()));
break;
}
}
found_var
} else {
Some((var_name.to_string(), var_len))
};
if let Some((_actual_name, actual_len)) = actual_var_name {
let actual_end_pos = var_start_pos + actual_len;
(
start,
actual_end_pos,
&result[var_start_pos..actual_end_pos],
)
} else {
(start, var_end_pos, var_name)
}
};
let replacement = std::env::var(var_name).unwrap_or_default();
result.replace_range(var_start..var_end, &replacement);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_toml_format() {
let content = r#"
path = "/usr/bin/git"
args = ["status", "-u"]
[signal_handling]
ignore_sigint = true
kill_on_exit = true
"#;
let config = ShimConfig::parse(content).unwrap();
assert_eq!(config.path, "/usr/bin/git");
assert_eq!(
config.args,
Some(vec!["status".to_string(), "-u".to_string()])
);
let signal_handling = config.signal_handling.unwrap();
assert_eq!(signal_handling.ignore_sigint, Some(true));
assert_eq!(signal_handling.kill_on_exit, Some(true));
}
#[test]
fn test_expand_env_vars() {
std::env::set_var("TEST_VAR", "test_value");
assert_eq!(expand_env_vars("$TEST_VAR"), "test_value");
assert_eq!(expand_env_vars("${TEST_VAR}"), "test_value");
assert_eq!(
expand_env_vars("prefix_$TEST_VAR_suffix"),
"prefix_test_value_suffix"
);
assert_eq!(expand_env_vars("${TEST_VAR}/path"), "test_value/path");
std::env::remove_var("TEST_VAR");
}
}