Skip to main content

rusty_commit/utils/
hooks.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::io::Write;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::time::Duration;
7
8/// Options for running hooks.
9pub struct HookOptions<'a> {
10    /// The name of the hook (e.g., "pre-commit", "pre-gen")
11    pub name: &'a str,
12    /// List of commands to execute
13    pub commands: Vec<String>,
14    /// Whether to treat hook failures as errors
15    pub strict: bool,
16    /// Maximum time to wait for each hook command
17    pub timeout: Duration,
18    /// Environment variables to pass to hook commands
19    pub envs: Vec<(&'a str, String)>,
20}
21
22/// Safely parse a command string into executable and arguments.
23/// Returns None if the command is empty or only whitespace.
24fn parse_command(cmd: &str) -> Option<(String, Vec<String>)> {
25    let cmd = cmd.trim();
26    if cmd.is_empty() {
27        return None;
28    }
29
30    // Use shell-like parsing: first word is command, rest are args
31    let mut parts = cmd.split_whitespace();
32    let executable = parts.next()?.to_string();
33    let args: Vec<String> = parts.map(|s| s.to_string()).collect();
34
35    Some((executable, args))
36}
37
38/// Execute a list of hook commands with the given options.
39///
40/// Each command is executed sequentially with the specified environment variables.
41/// If `strict` mode is enabled, any command failure will return an error.
42/// Otherwise, failures are printed as warnings and execution continues.
43///
44/// # Errors
45///
46/// Returns an error if a command fails in strict mode, if a command cannot be spawned,
47/// or if a command times out.
48///
49/// # Examples
50///
51/// ```
52/// use std::time::Duration;
53/// use rusty_commit::utils::hooks::{run_hooks, HookOptions};
54///
55/// let opts = HookOptions {
56///     name: "pre-commit",
57///     commands: vec!["echo 'Running tests'".to_string()],
58///     strict: true,
59///     timeout: Duration::from_secs(30),
60///     envs: vec![("RCO_VAR", "value".to_string())],
61/// };
62///
63/// let result = run_hooks(opts);
64/// ```
65pub fn run_hooks(opts: HookOptions) -> Result<()> {
66    for (idx, cmd) in opts.commands.iter().enumerate() {
67        // Parse command into executable and arguments
68        let (executable, args) = match parse_command(cmd) {
69            Some(parts) => parts,
70            None => {
71                eprintln!("Warning: Empty command in {} hook {}", opts.name, idx + 1);
72                continue;
73            }
74        };
75
76        // Security: warn about potentially dangerous commands
77        let executable_lower = executable.to_lowercase();
78        if executable_lower == "sh"
79            || executable_lower == "bash"
80            || executable_lower == "cmd"
81            || executable_lower == "powershell"
82        {
83            eprintln!(
84                "Warning: Shell execution in {} hook {} is deprecated for security reasons. \
85                Consider using direct command execution instead: {}",
86                opts.name,
87                idx + 1,
88                cmd
89            );
90        }
91
92        let mut command = Command::new(&executable);
93        command.args(&args);
94
95        for (k, v) in &opts.envs {
96            command.env(k, v);
97        }
98
99        command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
100
101        let mut child = command
102            .spawn()
103            .with_context(|| format!("Failed to start {} hook {}: {}", opts.name, idx + 1, cmd))?;
104
105        // Simple timeout: wait with polling
106        let start = std::time::Instant::now();
107        let status = loop {
108            if let Some(status) = child.try_wait()? {
109                break status;
110            }
111            if start.elapsed() > opts.timeout {
112                // Best-effort: attempt to terminate the child process
113                let _ = child.kill();
114                return Err(anyhow::anyhow!(
115                    "{} hook timed out after {:?} while running: {}",
116                    opts.name,
117                    opts.timeout,
118                    cmd
119                ));
120            }
121            std::thread::sleep(Duration::from_millis(100));
122        };
123
124        if !status.success() {
125            let msg = format!(
126                "{} hook failed (exit status {:?}) for command: {}",
127                opts.name,
128                status.code(),
129                cmd
130            );
131            if opts.strict {
132                return Err(anyhow::anyhow!(msg));
133            } else {
134                eprintln!("Warning: {}", msg);
135            }
136        }
137    }
138
139    Ok(())
140}
141
142/// Utility to write/read a temporary commit message file for hooks to modify.
143/// Uses NamedTempFile to avoid memory leaks while keeping the file accessible.
144pub fn write_temp_commit_file(initial: &str) -> Result<PathBuf> {
145    let mut temp_file = tempfile::NamedTempFile::new()?;
146    temp_file.write_all(initial.as_bytes())?;
147    // Persist the file so it survives beyond this function
148    let path = temp_file.into_temp_path();
149    let final_path = std::env::temp_dir().join(format!("rco-commit-{:}.txt", std::process::id()));
150    path.persist(&final_path)?;
151    Ok(final_path)
152}
153
154/// Cleanup function for temp commit file - call this after commit is done
155#[allow(dead_code)]
156pub fn cleanup_temp_commit_file(path: &PathBuf) {
157    let _ = fs::remove_file(path);
158}