Skip to main content

rippy_cli/
condition.rs

1//! Conditional rule evaluation — `when` clauses for context-aware rules.
2
3use std::path::Path;
4use std::process::Command;
5use std::time::Duration;
6
7use toml::Value;
8
9/// Runtime context for evaluating rule conditions.
10pub struct MatchContext<'a> {
11    /// Current git branch (cached), or `None` if not in a git repo.
12    pub branch: Option<&'a str>,
13    /// Working directory for cwd-relative checks.
14    pub cwd: &'a Path,
15}
16
17/// A single condition that must be true for a rule to apply.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Condition {
20    /// Branch name equals value.
21    BranchEq(String),
22    /// Branch name does not equal value.
23    BranchNot(String),
24    /// Branch name matches glob pattern.
25    BranchMatch(String),
26    /// Working directory is under the given path.
27    CwdUnder(String),
28    /// File exists at the given path.
29    FileExists(String),
30    /// Environment variable equals value.
31    EnvEq { name: String, value: String },
32    /// External command exits with code 0.
33    Exec(String),
34}
35
36/// Evaluate all conditions (AND). Returns true if all pass or list is empty.
37pub fn evaluate_all(conditions: &[Condition], ctx: &MatchContext) -> bool {
38    conditions.iter().all(|c| evaluate_one(c, ctx))
39}
40
41fn evaluate_one(cond: &Condition, ctx: &MatchContext) -> bool {
42    match cond {
43        Condition::BranchEq(expected) => ctx.branch == Some(expected.as_str()),
44        Condition::BranchNot(excluded) => ctx.branch != Some(excluded.as_str()),
45        Condition::BranchMatch(pattern) => ctx
46            .branch
47            .is_some_and(|b| crate::pattern::Pattern::new(pattern).matches(b)),
48        Condition::CwdUnder(base) => {
49            let base_path = if base == "." {
50                ctx.cwd.to_path_buf()
51            } else {
52                ctx.cwd.join(base)
53            };
54            ctx.cwd.starts_with(&base_path)
55        }
56        Condition::FileExists(path) => Path::new(path).exists(),
57        Condition::EnvEq { name, value } => {
58            std::env::var(name).ok().as_deref() == Some(value.as_str())
59        }
60        Condition::Exec(cmd) => evaluate_exec(cmd),
61    }
62}
63
64/// Run an external command with a 1-second timeout.
65fn evaluate_exec(cmd: &str) -> bool {
66    let child = Command::new("sh")
67        .args(["-c", cmd])
68        .stdout(std::process::Stdio::null())
69        .stderr(std::process::Stdio::null())
70        .spawn();
71
72    let mut child = match child {
73        Ok(c) => c,
74        Err(e) => {
75            eprintln!("[rippy] condition exec failed: {e}");
76            return false;
77        }
78    };
79
80    // Poll with timeout.
81    let deadline = std::time::Instant::now() + Duration::from_secs(1);
82    loop {
83        match child.try_wait() {
84            Ok(Some(status)) => return status.success(),
85            Ok(None) => {
86                if std::time::Instant::now() >= deadline {
87                    let _ = child.kill();
88                    let _ = child.wait();
89                    eprintln!("[rippy] condition exec timed out: {cmd}");
90                    return false;
91                }
92                std::thread::sleep(Duration::from_millis(10));
93            }
94            Err(e) => {
95                eprintln!("[rippy] condition exec failed: {e}");
96                return false;
97            }
98        }
99    }
100}
101
102/// Parse a TOML `when` value into a list of conditions.
103///
104/// # Errors
105///
106/// Returns an error string if the TOML structure is unrecognized.
107pub fn parse_conditions(value: &Value) -> Result<Vec<Condition>, String> {
108    let table = value.as_table().ok_or("'when' must be a TOML table")?;
109
110    let mut conditions = Vec::new();
111
112    for (key, val) in table {
113        match key.as_str() {
114            "branch" => conditions.push(parse_branch_condition(val)?),
115            "cwd" => conditions.push(parse_cwd_condition(val)?),
116            "file-exists" => {
117                let path = val.as_str().ok_or("'file-exists' must be a string")?;
118                conditions.push(Condition::FileExists(path.to_string()));
119            }
120            "env" => conditions.push(parse_env_condition(val)?),
121            "exec" => {
122                let cmd = val.as_str().ok_or("'exec' must be a string")?;
123                conditions.push(Condition::Exec(cmd.to_string()));
124            }
125            other => return Err(format!("unknown condition type: {other}")),
126        }
127    }
128
129    Ok(conditions)
130}
131
132fn parse_branch_condition(val: &Value) -> Result<Condition, String> {
133    let table = val.as_table().ok_or("'branch' must be a table")?;
134
135    if let Some(v) = table.get("eq") {
136        return Ok(Condition::BranchEq(
137            v.as_str().ok_or("branch.eq must be a string")?.to_string(),
138        ));
139    }
140    if let Some(v) = table.get("not") {
141        return Ok(Condition::BranchNot(
142            v.as_str().ok_or("branch.not must be a string")?.to_string(),
143        ));
144    }
145    if let Some(v) = table.get("match") {
146        return Ok(Condition::BranchMatch(
147            v.as_str()
148                .ok_or("branch.match must be a string")?
149                .to_string(),
150        ));
151    }
152
153    Err("branch condition must have 'eq', 'not', or 'match' key".into())
154}
155
156fn parse_cwd_condition(val: &Value) -> Result<Condition, String> {
157    let table = val.as_table().ok_or("'cwd' must be a table")?;
158    if let Some(v) = table.get("under") {
159        return Ok(Condition::CwdUnder(
160            v.as_str().ok_or("cwd.under must be a string")?.to_string(),
161        ));
162    }
163    Err("cwd condition must have 'under' key".into())
164}
165
166fn parse_env_condition(val: &Value) -> Result<Condition, String> {
167    let table = val.as_table().ok_or("'env' must be a table")?;
168    let name = table
169        .get("name")
170        .and_then(Value::as_str)
171        .ok_or("env.name must be a string")?;
172    let value = table
173        .get("eq")
174        .and_then(Value::as_str)
175        .ok_or("env.eq must be a string")?;
176    Ok(Condition::EnvEq {
177        name: name.to_string(),
178        value: value.to_string(),
179    })
180}
181
182/// Detect the current git branch from a working directory.
183///
184/// Returns `None` if not in a git repository or git is not available.
185#[must_use]
186pub fn detect_git_branch(cwd: &Path) -> Option<String> {
187    let output = Command::new("git")
188        .args(["symbolic-ref", "--short", "HEAD"])
189        .current_dir(cwd)
190        .stdout(std::process::Stdio::piped())
191        .stderr(std::process::Stdio::null())
192        .output()
193        .ok()?;
194
195    if !output.status.success() {
196        return None;
197    }
198
199    String::from_utf8(output.stdout)
200        .ok()
201        .map(|s| s.trim().to_string())
202        .filter(|s| !s.is_empty())
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used)]
207mod tests {
208    use super::*;
209
210    fn ctx_with_branch<'a>(branch: Option<&'a str>, cwd: &'a Path) -> MatchContext<'a> {
211        MatchContext { branch, cwd }
212    }
213
214    #[test]
215    fn branch_eq_matches() {
216        let ctx = ctx_with_branch(Some("main"), Path::new("/tmp"));
217        assert!(evaluate_one(&Condition::BranchEq("main".into()), &ctx));
218        assert!(!evaluate_one(&Condition::BranchEq("develop".into()), &ctx));
219    }
220
221    #[test]
222    fn branch_not_matches() {
223        let ctx = ctx_with_branch(Some("feature/foo"), Path::new("/tmp"));
224        assert!(evaluate_one(&Condition::BranchNot("main".into()), &ctx));
225        assert!(!evaluate_one(
226            &Condition::BranchNot("feature/foo".into()),
227            &ctx
228        ));
229    }
230
231    #[test]
232    fn branch_match_glob() {
233        let ctx = ctx_with_branch(Some("feat/my-feature"), Path::new("/tmp"));
234        assert!(evaluate_one(&Condition::BranchMatch("feat/*".into()), &ctx));
235        assert!(!evaluate_one(&Condition::BranchMatch("fix/*".into()), &ctx));
236    }
237
238    #[test]
239    fn branch_none_fails_all() {
240        let ctx = ctx_with_branch(None, Path::new("/tmp"));
241        assert!(!evaluate_one(&Condition::BranchEq("main".into()), &ctx));
242        assert!(evaluate_one(&Condition::BranchNot("main".into()), &ctx));
243    }
244
245    #[test]
246    fn cwd_under_self() {
247        let cwd = std::env::current_dir().unwrap();
248        let ctx = ctx_with_branch(None, &cwd);
249        assert!(evaluate_one(&Condition::CwdUnder(".".into()), &ctx));
250    }
251
252    #[test]
253    fn file_exists_condition() {
254        assert!(evaluate_one(
255            &Condition::FileExists("Cargo.toml".into()),
256            &MatchContext {
257                branch: None,
258                cwd: Path::new(".")
259            }
260        ));
261        assert!(!evaluate_one(
262            &Condition::FileExists("nonexistent_file_xyz".into()),
263            &MatchContext {
264                branch: None,
265                cwd: Path::new(".")
266            }
267        ));
268    }
269
270    #[test]
271    fn env_eq_condition() {
272        // SAFETY: test runs single-threaded via cargo test.
273        unsafe { std::env::set_var("RIPPY_TEST_VAR", "hello") };
274        let ctx = MatchContext {
275            branch: None,
276            cwd: Path::new("."),
277        };
278        assert!(evaluate_one(
279            &Condition::EnvEq {
280                name: "RIPPY_TEST_VAR".into(),
281                value: "hello".into()
282            },
283            &ctx
284        ));
285        assert!(!evaluate_one(
286            &Condition::EnvEq {
287                name: "RIPPY_TEST_VAR".into(),
288                value: "world".into()
289            },
290            &ctx
291        ));
292        unsafe { std::env::remove_var("RIPPY_TEST_VAR") };
293    }
294
295    #[test]
296    fn evaluate_all_empty_is_true() {
297        let ctx = ctx_with_branch(None, Path::new("/tmp"));
298        assert!(evaluate_all(&[], &ctx));
299    }
300
301    #[test]
302    fn evaluate_all_and_logic() {
303        let ctx = ctx_with_branch(Some("main"), Path::new("/tmp"));
304        let conditions = vec![
305            Condition::BranchEq("main".into()),
306            Condition::BranchNot("develop".into()),
307        ];
308        assert!(evaluate_all(&conditions, &ctx));
309
310        let conditions_fail = vec![
311            Condition::BranchEq("main".into()),
312            Condition::BranchEq("develop".into()), // fails
313        ];
314        assert!(!evaluate_all(&conditions_fail, &ctx));
315    }
316
317    #[test]
318    fn parse_branch_eq() {
319        let toml: Value = toml::from_str(r#"branch = { eq = "main" }"#).unwrap();
320        let conds = parse_conditions(&toml).unwrap();
321        assert_eq!(conds, vec![Condition::BranchEq("main".into())]);
322    }
323
324    #[test]
325    fn parse_branch_not() {
326        let toml: Value = toml::from_str(r#"branch = { not = "main" }"#).unwrap();
327        let conds = parse_conditions(&toml).unwrap();
328        assert_eq!(conds, vec![Condition::BranchNot("main".into())]);
329    }
330
331    #[test]
332    fn parse_branch_match() {
333        let toml: Value = toml::from_str(r#"branch = { match = "feat/*" }"#).unwrap();
334        let conds = parse_conditions(&toml).unwrap();
335        assert_eq!(conds, vec![Condition::BranchMatch("feat/*".into())]);
336    }
337
338    #[test]
339    fn parse_cwd_under() {
340        let toml: Value = toml::from_str(r#"cwd = { under = "." }"#).unwrap();
341        let conds = parse_conditions(&toml).unwrap();
342        assert_eq!(conds, vec![Condition::CwdUnder(".".into())]);
343    }
344
345    #[test]
346    fn parse_file_exists() {
347        let toml: Value = toml::from_str(r#"file-exists = "Cargo.toml""#).unwrap();
348        let conds = parse_conditions(&toml).unwrap();
349        assert_eq!(conds, vec![Condition::FileExists("Cargo.toml".into())]);
350    }
351
352    #[test]
353    fn parse_env_eq() {
354        let toml: Value = toml::from_str(r#"env = { name = "HOME", eq = "/home/user" }"#).unwrap();
355        let conds = parse_conditions(&toml).unwrap();
356        assert_eq!(
357            conds,
358            vec![Condition::EnvEq {
359                name: "HOME".into(),
360                value: "/home/user".into()
361            }]
362        );
363    }
364
365    #[test]
366    fn parse_exec() {
367        let toml: Value = toml::from_str(r#"exec = "true""#).unwrap();
368        let conds = parse_conditions(&toml).unwrap();
369        assert_eq!(conds, vec![Condition::Exec("true".into())]);
370    }
371
372    #[test]
373    fn parse_unknown_condition_errors() {
374        let toml: Value = toml::from_str(r#"unknown = "value""#).unwrap();
375        assert!(parse_conditions(&toml).is_err());
376    }
377
378    #[test]
379    fn exec_true_succeeds() {
380        assert!(evaluate_exec("true"));
381    }
382
383    #[test]
384    fn exec_false_fails() {
385        assert!(!evaluate_exec("false"));
386    }
387
388    #[test]
389    fn detect_git_branch_in_fresh_repo() {
390        let dir = tempfile::TempDir::new().unwrap();
391        // Initialize a git repo with a branch.
392        std::process::Command::new("git")
393            .args(["init", "-b", "test-branch"])
394            .current_dir(dir.path())
395            .output()
396            .unwrap();
397        // Need at least one commit for symbolic-ref to work.
398        std::process::Command::new("git")
399            .args(["commit", "--allow-empty", "-m", "init"])
400            .current_dir(dir.path())
401            .output()
402            .unwrap();
403
404        let branch = detect_git_branch(dir.path());
405        assert_eq!(branch.as_deref(), Some("test-branch"));
406    }
407
408    #[test]
409    fn detect_git_branch_not_a_repo() {
410        let dir = tempfile::TempDir::new().unwrap();
411        let branch = detect_git_branch(dir.path());
412        assert!(branch.is_none());
413    }
414}