Skip to main content

gitversion_rs/
exec.rs

1//! External command execution hooks (similar to the semantic-release exec plugin).
2//!
3//! Exposes computed version variables as `GitVersion_*` environment variables and
4//! `{Variable}`/`{env:VAR}` template tokens, then runs lifecycle hook commands.
5//! The `version` hook can modify the version by writing to stdout
6//! (which overwrites `next-version` and triggers a recalculation).
7
8use crate::output::VersionVariables;
9use anyhow::{bail, Context, Result};
10use regex::Regex;
11use rust_i18n::t;
12use std::collections::BTreeMap;
13use std::path::Path;
14use std::process::{Command, Stdio};
15
16/// Execution order for side-effect hooks.
17pub const HOOK_ORDER: [&str; 4] = ["verify", "prepare", "publish", "success"];
18
19/// Substitute `{Variable}` / `{env:VAR}` tokens in a command string (unknown tokens are left as-is).
20fn render(cmd: &str, map: &BTreeMap<String, String>) -> String {
21    let re = Regex::new(r"\{(?<t>[A-Za-z0-9_:]+)\}").unwrap();
22    re.replace_all(cmd, |c: &regex::Captures| {
23        let t = &c["t"];
24        if let Some(env_var) = t.strip_prefix("env:") {
25            std::env::var(env_var).unwrap_or_default()
26        } else if let Some(v) = map.get(t) {
27            v.clone()
28        } else {
29            format!("{{{t}}}") // Unknown tokens are preserved as-is.
30        }
31    })
32    .into_owned()
33}
34
35/// Convert version variables to `GitVersion_*` environment variable pairs.
36fn env_vars(vars: &VersionVariables) -> Vec<(String, String)> {
37    vars.to_map()
38        .into_iter()
39        .map(|(k, v)| (format!("GitVersion_{k}"), v))
40        .collect()
41}
42
43/// Standard Cargo `CARGO_PKG_VERSION*` environment variables derived from the version.
44///
45/// These mirror the names Cargo itself sets at build time, so a Rust build or script
46/// invoked from an exec hook can pick up the GitVersion-computed version using the
47/// familiar variable names (e.g. a `build.rs` reading `CARGO_PKG_VERSION`).
48fn cargo_env_vars(vars: &VersionVariables) -> Vec<(String, String)> {
49    vec![
50        ("CARGO_PKG_VERSION".into(), vars.sem_ver.clone()),
51        ("CARGO_PKG_VERSION_MAJOR".into(), vars.major.to_string()),
52        ("CARGO_PKG_VERSION_MINOR".into(), vars.minor.to_string()),
53        ("CARGO_PKG_VERSION_PATCH".into(), vars.patch.to_string()),
54        ("CARGO_PKG_VERSION_PRE".into(), vars.pre_release_tag.clone()),
55    ]
56}
57
58/// Run a command via the shell. If `capture` is true, collect stdout and return it; otherwise inherit.
59fn run_command(
60    cmd: &str,
61    vars: &VersionVariables,
62    work_dir: &Path,
63    capture: bool,
64    dry_run: bool,
65) -> Result<Option<String>> {
66    let rendered = render(cmd, &vars.to_map());
67    if dry_run {
68        log::info!("{}", t!("exec.dry_run", cmd = rendered));
69        eprintln!("[dry-run] {rendered}");
70        return Ok(None);
71    }
72    log::info!("{}", t!("exec.running", cmd = rendered));
73
74    let (program, flag) = if cfg!(windows) {
75        ("cmd", "/C")
76    } else {
77        ("sh", "-c")
78    };
79    let mut command = Command::new(program);
80    command
81        .arg(flag)
82        .arg(&rendered)
83        .current_dir(work_dir)
84        .envs(env_vars(vars))
85        .envs(cargo_env_vars(vars));
86    if capture {
87        command.stdout(Stdio::piped()).stderr(Stdio::inherit());
88    }
89
90    if capture {
91        let output = command
92            .output()
93            .with_context(|| t!("exec.run_failed", cmd = rendered))?;
94        if !output.status.success() {
95            bail!(
96                "{}",
97                t!(
98                    "exec.cmd_failed",
99                    code = format!("{:?}", output.status.code()),
100                    cmd = rendered
101                )
102            );
103        }
104        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
105    } else {
106        let status = command
107            .status()
108            .with_context(|| t!("exec.run_failed", cmd = rendered))?;
109        if !status.success() {
110            bail!(
111                "{}",
112                t!(
113                    "exec.cmd_failed",
114                    code = format!("{:?}", status.code()),
115                    cmd = rendered
116                )
117            );
118        }
119        Ok(None)
120    }
121}
122
123/// Run the `version` hook (or `--exec-version`). Returns the first non-empty line from stdout.
124/// The caller applies the result as `next-version` and recalculates.
125pub fn run_version_hook(
126    cmd: &str,
127    vars: &VersionVariables,
128    work_dir: &Path,
129    dry_run: bool,
130) -> Result<Option<String>> {
131    let out = run_command(cmd, vars, work_dir, true, dry_run)?;
132    Ok(out.and_then(|s| {
133        s.lines()
134            .map(str::trim)
135            .find(|l| !l.is_empty())
136            .map(String::from)
137    }))
138}
139
140/// Run side-effect hooks (verify/prepare/publish/success) in order.
141/// On failure, runs the `fail` hook if present and then propagates the error.
142/// `extra_prepare` is the temporary prepare command supplied via `--exec` (run after the config's prepare).
143pub fn run_hooks(
144    hooks: &BTreeMap<String, String>,
145    extra_prepare: Option<&str>,
146    vars: &VersionVariables,
147    work_dir: &Path,
148    dry_run: bool,
149) -> Result<()> {
150    let mut result = Ok(());
151    'outer: for &name in &HOOK_ORDER {
152        if let Some(cmd) = hooks.get(name) {
153            if let Err(e) = run_command(cmd, vars, work_dir, false, dry_run) {
154                result = Err(e.context(t!("exec.hook_failed", name = name).to_string()));
155                break 'outer;
156            }
157        }
158        if name == "prepare" {
159            if let Some(cmd) = extra_prepare {
160                if let Err(e) = run_command(cmd, vars, work_dir, false, dry_run) {
161                    result = Err(e.context(t!("exec.exec_prepare_failed").to_string()));
162                    break 'outer;
163                }
164            }
165        }
166    }
167
168    if result.is_err() {
169        if let Some(fail_cmd) = hooks.get("fail") {
170            log::warn!("{}", t!("exec.running_fail_hook"));
171            let _ = run_command(fail_cmd, vars, work_dir, false, dry_run);
172        }
173    }
174    result
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn render_substitutes_and_preserves() {
183        let mut m = BTreeMap::new();
184        m.insert("SemVer".to_string(), "1.2.3".to_string());
185        assert_eq!(render("echo {SemVer}", &m), "echo 1.2.3");
186        // Unknown tokens are preserved.
187        assert_eq!(render("echo {Unknown}", &m), "echo {Unknown}");
188        // Shell variables ($) are not affected.
189        assert_eq!(render("echo $HOME {SemVer}", &m), "echo $HOME 1.2.3");
190    }
191
192    #[test]
193    fn cargo_env_vars_mirror_cargo_names() {
194        let vars = VersionVariables {
195            major: 1,
196            minor: 2,
197            patch: 3,
198            sem_ver: "1.2.3-alpha.4".into(),
199            pre_release_tag: "alpha.4".into(),
200            ..Default::default()
201        };
202        let map: BTreeMap<_, _> = cargo_env_vars(&vars).into_iter().collect();
203        assert_eq!(map["CARGO_PKG_VERSION"], "1.2.3-alpha.4");
204        assert_eq!(map["CARGO_PKG_VERSION_MAJOR"], "1");
205        assert_eq!(map["CARGO_PKG_VERSION_MINOR"], "2");
206        assert_eq!(map["CARGO_PKG_VERSION_PATCH"], "3");
207        assert_eq!(map["CARGO_PKG_VERSION_PRE"], "alpha.4");
208    }
209}