use std::collections::BTreeMap;
use std::path::Path;
use std::process::{Command as StdCommand, Stdio};
use std::sync::OnceLock;
use anyhow::{Context as _, Result, anyhow};
use regex::Regex;
use tracing::{debug, info};
use crate::input::Input;
use crate::matcher::CaptureMap;
use crate::platform;
use crate::template::{Context, build_context, render};
fn references_input(text: &str) -> bool {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"\{[{%]-?[^{}]*?\b(input|file_\w+|url_\w+)\b[^{}]*?-?[%}]\}")
.expect("static regex")
});
re.is_match(text)
}
fn references_passthrough(text: &str) -> bool {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"\{[{%]-?[^{}]*?\bpassthrough\b[^{}]*?-?[%}]\}").expect("static regex")
});
re.is_match(text)
}
fn is_passthrough_placeholder(text: &str) -> bool {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"^\s*\{\{-?\s*passthrough\s*-?\}\}\s*$").expect("static regex")
});
re.is_match(text)
}
#[derive(Debug, Clone)]
pub struct ExecBackend {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub append_inputs: Option<bool>,
pub append_passthrough: Option<bool>,
pub gui: bool,
}
pub struct DispatchCtx<'a> {
pub inputs: &'a [Input],
pub passthrough: &'a [String],
pub mode: &'a str,
pub sync: bool,
pub group: &'a str,
pub rule_name: &'a str,
pub vars: &'a BTreeMap<String, toml::Value>,
pub cwd: &'a str,
pub cap: &'a CaptureMap,
}
impl ExecBackend {
pub fn dispatch(&self, dctx: DispatchCtx<'_>) -> Result<()> {
let rendered_args = self.render_args(&dctx)?;
let args_joined = self.args.join("\n");
let append_inputs = self
.append_inputs
.unwrap_or_else(|| !references_input(&args_joined));
let append_passthrough = self
.append_passthrough
.unwrap_or_else(|| !references_passthrough(&args_joined));
let mut cmd = StdCommand::new(&self.command);
cmd.args(&rendered_args);
if append_passthrough {
for p in dctx.passthrough {
cmd.arg(p);
}
}
if append_inputs {
for i in dctx.inputs {
cmd.arg(i.display_string());
}
}
for (k, v) in &self.env {
cmd.env(k, v);
}
debug!(
command = %self.command,
args = ?rendered_args,
passthrough = ?dctx.passthrough,
count = dctx.inputs.len(),
append_inputs,
append_passthrough,
sync = dctx.sync,
mode = dctx.mode,
"exec dispatch"
);
if dctx.sync {
self.run_sync(&mut cmd)
} else {
self.run_detached(&mut cmd)
}
}
fn render_args(&self, dctx: &DispatchCtx<'_>) -> Result<Vec<String>> {
let ctx = build_context(Context {
input: dctx.inputs.first(),
command: &self.command,
cwd: dctx.cwd,
group: dctx.group,
rule_name: dctx.rule_name,
vars: dctx.vars,
cap: dctx.cap,
passthrough: dctx.passthrough,
});
let mut tera = crate::template::new_engine();
let mut out: Vec<String> = Vec::with_capacity(self.args.len());
for t in &self.args {
if is_passthrough_placeholder(t) {
for p in dctx.passthrough {
out.push(p.clone());
}
} else {
let rendered = render(&mut tera, t, &ctx)
.with_context(|| format!("rendering arg template: {t}"))?;
out.push(rendered);
}
}
Ok(out)
}
fn run_detached(&self, cmd: &mut StdCommand) -> Result<()> {
info!(command = %self.command, "spawning detached");
platform::spawn_detached(cmd, self.gui, Path::new(""))
.with_context(|| format!("failed to spawn {}", self.command))
}
fn run_sync(&self, cmd: &mut StdCommand) -> Result<()> {
info!(command = %self.command, "spawning sync (inherit stdio)");
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.with_context(|| format!("failed to run {}", self.command))?;
if status.success() {
Ok(())
} else {
Err(anyhow!("{} exited with status {}", self.command, status))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn references_input_detects_input_and_derived_vars() {
assert!(references_input("{{ input }}"));
assert!(references_input("prefix {{ input }} suffix"));
assert!(references_input("--file={{ file_path }}"));
assert!(references_input("--stem={{ file_stem }}"));
assert!(references_input("--host={{ url_host }}"));
assert!(references_input("--query={{ url_query }}"));
assert!(references_input("{{ input | upper }}"));
assert!(references_input("{{- input -}}"));
assert!(references_input("{{- file_path }}"));
assert!(references_input("{% if input %}yes{% endif %}"));
assert!(references_input("{% if file_path != '' %}y{% endif %}"));
assert!(!references_input("{{ cap.1 }}"));
assert!(!references_input("{{ cap.name }}"));
assert!(!references_input("{{ group }}"));
assert!(!references_input("{{ rule }}"));
assert!(!references_input("{{ command_name }}"));
assert!(!references_input("--input-file"));
assert!(!references_input("pass an input here"));
assert!(!references_input(""));
assert!(!references_input("--flag"));
}
#[test]
fn references_passthrough_detects_various_forms() {
assert!(references_passthrough("{{ passthrough }}"));
assert!(references_passthrough("{{ passthrough | join(sep=' ') }}"));
assert!(references_passthrough("{{- passthrough -}}"));
assert!(references_passthrough(
"{% for p in passthrough %}{{ p }}{% endfor %}"
));
assert!(references_passthrough("{% if passthrough %}yes{% endif %}"));
assert!(!references_passthrough("{{ input }}"));
assert!(!references_passthrough("{{ cap.1 }}"));
assert!(!references_passthrough("--passthrough-mode=x"));
assert!(!references_passthrough(""));
}
#[test]
fn is_passthrough_placeholder_matches_exact_forms_only() {
assert!(is_passthrough_placeholder("{{ passthrough }}"));
assert!(is_passthrough_placeholder("{{passthrough}}"));
assert!(is_passthrough_placeholder(" {{ passthrough }} "));
assert!(is_passthrough_placeholder("{{- passthrough -}}"));
assert!(is_passthrough_placeholder("{{-passthrough-}}"));
assert!(!is_passthrough_placeholder(
"{{ passthrough | join(sep=' ') }}"
));
assert!(!is_passthrough_placeholder("prefix {{ passthrough }}"));
assert!(!is_passthrough_placeholder("{{ passthrough }} suffix"));
assert!(!is_passthrough_placeholder("{% for p in passthrough %}"));
assert!(!is_passthrough_placeholder("{{ input }}"));
assert!(!is_passthrough_placeholder(""));
}
#[test]
fn renders_template_args_with_file_context() {
let backend = ExecBackend {
command: "echo".into(),
args: vec![
"--file={{ file_stem }}".into(),
"--ext={{ file_ext }}".into(),
],
env: BTreeMap::new(),
append_inputs: Some(true),
append_passthrough: None,
gui: false,
};
let inputs = vec![Input::File(PathBuf::from("/tmp/hello.rs"))];
let passthrough: Vec<String> = Vec::new();
let vars = BTreeMap::new();
let cap = CaptureMap::new();
let dctx = DispatchCtx {
inputs: &inputs,
passthrough: &passthrough,
mode: "new",
sync: false,
group: "g",
rule_name: "r",
vars: &vars,
cwd: "/tmp",
cap: &cap,
};
let args = backend.render_args(&dctx).unwrap();
assert_eq!(args, vec!["--file=hello", "--ext=rs"]);
}
}