use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use minijinja::value::Kwargs;
use minijinja::{Environment, Error, ErrorKind, UndefinedBehavior, Value};
use serde::{Deserialize, Serialize};
use crate::dirs::{BaseDirs, expand_tilde};
use crate::vsort::version_cmp;
pub fn build_env(dirs: &BaseDirs, vars: &toml::Table) -> Environment<'static> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.add_global("home", dirs.home.display().to_string());
env.add_global("config_home", dirs.config_home.display().to_string());
env.add_global("data_home", dirs.data_home.display().to_string());
env.add_global("cache_home", dirs.cache_home.display().to_string());
env.add_global("state_home", dirs.state_home.display().to_string());
env.add_global("os", std::env::consts::OS);
env.add_global("arch", std::env::consts::ARCH);
for (k, v) in vars {
env.add_global(k.clone(), Value::from_serialize(v));
}
let home = dirs.home.clone();
env.add_filter("glob", move |pattern: String| {
glob_expand(&pattern, &home)
.map_err(|e| Error::new(ErrorKind::InvalidOperation, e.to_string()))
});
let cache_dir = dirs.qpath_cache_dir().join("shell");
env.add_filter("shell", move |command: String, kwargs: Kwargs| {
let cache_ttl: Option<u64> = kwargs.get("cache_ttl")?;
kwargs.assert_all_used()?;
match cache_ttl {
Some(secs) => shell_run_cached(&command, &cache_dir, Duration::from_secs(secs)),
None => shell_run(&command),
}
.map_err(|e| Error::new(ErrorKind::InvalidOperation, e))
});
env.add_filter("vsort", |items: Vec<String>| {
let mut items = items;
items.sort_by(|a, b| version_cmp(a, b));
items
});
env
}
fn shell_run(command: &str) -> Result<String, String> {
let output = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::null())
.output()
.map_err(|e| format!("cannot run sh: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim_end();
return Err(format!(
"command failed ({}): {command}{}{stderr}",
output.status,
if stderr.is_empty() { "" } else { ": " }
));
}
let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned();
stdout.truncate(stdout.trim_end_matches(['\r', '\n']).len());
Ok(stdout)
}
#[derive(Serialize, Deserialize)]
struct CacheEntry {
command: String,
output: String,
}
fn shell_run_cached(command: &str, cache_dir: &Path, ttl: Duration) -> Result<String, String> {
let path = cache_dir.join(format!("{:016x}.json", fnv1a(command)));
if let Some(output) = read_cache(&path, command, ttl) {
return Ok(output);
}
let output = shell_run(command)?;
if let Err(e) = write_cache(&path, command, &output) {
eprintln!("qpath: warning: cannot write {}: {e}", path.display());
}
Ok(output)
}
fn read_cache(path: &Path, command: &str, ttl: Duration) -> Option<String> {
let age = fs::metadata(path).ok()?.modified().ok()?.elapsed().ok()?;
if age >= ttl {
return None;
}
let entry: CacheEntry = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?;
(entry.command == command).then_some(entry.output)
}
fn write_cache(path: &Path, command: &str, output: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let entry = CacheEntry {
command: command.to_string(),
output: output.to_string(),
};
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
fs::write(&tmp, serde_json::to_string(&entry)?)?;
fs::rename(&tmp, path)
}
fn fnv1a(s: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
hash
}
fn glob_expand(pattern: &str, home: &Path) -> Result<Vec<String>, glob::PatternError> {
let mut results = Vec::new();
for alternative in brace_expand::brace_expand(pattern) {
let expanded = expand_tilde(&alternative, home);
let dirs_only = expanded.ends_with('/');
let trimmed = expanded.trim_end_matches('/');
let pat = if trimmed.is_empty() { "/" } else { trimmed };
let mut matches: Vec<String> = Vec::new();
for entry in glob::glob(pat)?.flatten() {
if dirs_only && !entry.is_dir() {
continue;
}
let mut s = entry.to_string_lossy().into_owned();
if dirs_only {
s.push('/');
}
matches.push(s);
}
matches.sort();
matches.dedup();
results.append(&mut matches);
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn glob_sorts_lexically_and_marks_directories() {
let tmp = tempfile::tempdir().unwrap();
for v in ["29.9", "29.10", "30.1"] {
fs::create_dir_all(tmp.path().join(v).join("sources")).unwrap();
}
fs::write(tmp.path().join("31"), "").unwrap();
let pat = format!("{}/[0-9]*/sources/", tmp.path().display());
let got = glob_expand(&pat, Path::new("/nonexistent")).unwrap();
let expected: Vec<String> = ["29.10", "29.9", "30.1"]
.iter()
.map(|v| format!("{}/{v}/sources/", tmp.path().display()))
.collect();
assert_eq!(got, expected);
let pat = format!("{}/*", tmp.path().display());
let got = glob_expand(&pat, Path::new("/nonexistent")).unwrap();
assert_eq!(got.len(), 4);
assert!(got.iter().all(|p| !p.ends_with('/')));
}
#[test]
fn vsort_orders_version_fragments() {
let mut env = Environment::new();
env.add_filter("vsort", |items: Vec<String>| {
let mut items = items;
items.sort_by(|a, b| version_cmp(a, b));
items
});
let tmpl = env.render_str(
"{{ ['v29.9', 'v29.10', 'v30.1'] | vsort | join(',') }}",
minijinja::context! {},
);
assert_eq!(tmpl.unwrap(), "v29.9,v29.10,v30.1");
}
#[test]
fn glob_brace_expansion() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".Brewfile"), "").unwrap();
fs::write(tmp.path().join("a.txt"), "").unwrap();
fs::write(tmp.path().join("b.txt"), "").unwrap();
let dir = tmp.path().display();
let pat = format!("{dir}/{{.Debfile,.Brewfile}}");
let got = glob_expand(&pat, Path::new("/nonexistent")).unwrap();
assert_eq!(got, vec![format!("{dir}/.Brewfile")]);
let pat = format!("{dir}/{{a,b}}.txt");
let got = glob_expand(&pat, Path::new("/nonexistent")).unwrap();
assert_eq!(got, vec![format!("{dir}/a.txt"), format!("{dir}/b.txt")]);
let pat = format!("{dir}/a.txt");
let got = glob_expand(&pat, Path::new("/nonexistent")).unwrap();
assert_eq!(got, vec![format!("{dir}/a.txt")]);
}
#[test]
fn glob_brace_expands_tilde_per_alternative() {
let home = tempfile::tempdir().unwrap();
fs::write(home.path().join(".zshrc"), "").unwrap();
let other = tempfile::tempdir().unwrap();
fs::write(other.path().join("profile"), "").unwrap();
let pat = format!("{{~/.zshrc,{}/profile}}", other.path().display());
let got = glob_expand(&pat, home.path()).unwrap();
assert!(
got.contains(&format!("{}/.zshrc", home.path().display())),
"{got:?}"
);
assert!(
got.contains(&format!("{}/profile", other.path().display())),
"{got:?}"
);
assert_eq!(got.len(), 2);
}
#[test]
fn shell_strips_trailing_newlines() {
assert_eq!(shell_run("printf %s hello").unwrap(), "hello");
assert_eq!(shell_run("echo hello").unwrap(), "hello");
assert_eq!(shell_run("printf 'a b\\n\\n'").unwrap(), "a b");
assert_eq!(shell_run("printf 'a\\nb\\n'").unwrap(), "a\nb");
}
#[test]
fn shell_reports_failure() {
let err = shell_run("echo oops >&2; exit 3").unwrap_err();
assert!(err.contains("exit status: 3"), "{err}");
assert!(err.contains("oops"), "{err}");
}
#[test]
fn shell_cache_hit_skips_execution() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join("cache");
let counter = tmp.path().join("counter");
let cmd = format!("echo x >> {}; echo result", counter.display());
let ttl = Duration::from_secs(3600);
assert_eq!(shell_run_cached(&cmd, &cache_dir, ttl).unwrap(), "result");
assert_eq!(shell_run_cached(&cmd, &cache_dir, ttl).unwrap(), "result");
assert_eq!(fs::read_to_string(&counter).unwrap(), "x\n");
assert_eq!(
shell_run_cached(&cmd, &cache_dir, Duration::ZERO).unwrap(),
"result"
);
assert_eq!(fs::read_to_string(&counter).unwrap(), "x\nx\n");
}
#[test]
fn shell_cache_ignores_collisions_and_failures() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join("cache");
let ttl = Duration::from_secs(3600);
let path = cache_dir.join(format!("{:016x}.json", fnv1a("printf %s new")));
fs::create_dir_all(&cache_dir).unwrap();
fs::write(&path, r#"{"command":"other","output":"stale"}"#).unwrap();
assert_eq!(
shell_run_cached("printf %s new", &cache_dir, ttl).unwrap(),
"new"
);
assert!(shell_run_cached("exit 1", &cache_dir, ttl).is_err());
let path = cache_dir.join(format!("{:016x}.json", fnv1a("exit 1")));
assert!(!path.exists());
}
}