1use std::path::Path;
4use std::process::Command;
5use std::time::Duration;
6
7use toml::Value;
8
9pub struct MatchContext<'a> {
11 pub branch: Option<&'a str>,
13 pub cwd: &'a Path,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Condition {
20 BranchEq(String),
22 BranchNot(String),
24 BranchMatch(String),
26 CwdUnder(String),
28 FileExists(String),
30 EnvEq { name: String, value: String },
32 Exec(String),
34}
35
36pub 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
64fn 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 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
102pub 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#[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 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()), ];
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 std::process::Command::new("git")
393 .args(["init", "-b", "test-branch"])
394 .current_dir(dir.path())
395 .output()
396 .unwrap();
397 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}