From 03d8515e9b12ade759ebb15bee2a6ad741b22d7e Mon Sep 17 00:00:00 2001 From: Evgeny Abramovich Date: Mon, 13 Apr 2026 11:02:44 -0400 Subject: [PATCH] feat: implement parallel rule execution with tokio semaphore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules are now split into two execution modes based on what they do: - **Sync** (default): branch-name-*, commit-message-*, suppress-files, suppress-string — run sequentially in a dedicated thread, one after another. - **Async**: exec, shell, write-file, copy-files, delete-files — each gets its own OS thread and runs concurrently with the other async rules. Both groups start simultaneously so sync validation and heavy file/process operations overlap in time, minimising total hook latency. Concurrency of async rules is bounded by a `tokio::sync::Semaphore` (default limit: 10). Scoped OS threads acquire permits via `Handle::block_on` which is safe to call from non-tokio threads and does not require the caller to be inside a tokio task. Implementation details: - Add `ExecutionMode::{Sync,Async}` enum and a `Rule::execution_mode()` trait method (defaults to `Sync`) in `fisherman_core/src/rules/rule.rs`. - Five dynamic rule types override `execution_mode()` to return `Async`. - `HandleCommand::exec` now has three phases: (1) sequential preparation (extend context, evaluate `when` conditions), (2) parallel execution via `std::thread::scope` + tokio semaphore, (3) report in declaration order. - All new behaviour is covered by unit tests in `handle.rs` and `rule.rs`. Co-Authored-By: Claude Sonnet 4.6 --- fisherman_core/Cargo.toml | 1 + fisherman_core/src/commands/handle.rs | 518 ++++++++++++++++++++--- fisherman_core/src/rules/copy_files.rs | 6 +- fisherman_core/src/rules/delete_files.rs | 6 +- fisherman_core/src/rules/exec_rule.rs | 6 +- fisherman_core/src/rules/mod.rs | 2 +- fisherman_core/src/rules/rule.rs | 119 +++++- fisherman_core/src/rules/shell_script.rs | 6 +- fisherman_core/src/rules/write_file.rs | 6 +- 9 files changed, 595 insertions(+), 75 deletions(-) diff --git a/fisherman_core/Cargo.toml b/fisherman_core/Cargo.toml index 2f57a88e..a8443a20 100644 --- a/fisherman_core/Cargo.toml +++ b/fisherman_core/Cargo.toml @@ -23,6 +23,7 @@ typetag = "0.2.21" serde_json = "1.0.149" once_cell = "1" rayon = "1.10" +tokio = { version = "1", features = ["sync", "rt-multi-thread"] } [dev-dependencies] rstest = "0.26.1" diff --git a/fisherman_core/src/commands/handle.rs b/fisherman_core/src/commands/handle.rs index 3420ee1f..91473328 100644 --- a/fisherman_core/src/commands/handle.rs +++ b/fisherman_core/src/commands/handle.rs @@ -1,4 +1,5 @@ use crate::commands::command::CliCommand; +use crate::rules::ExecutionMode; use crate::ui::hook_display; use crate::Context; use crate::GitHook; @@ -6,6 +7,11 @@ use crate::RuleResult; use anyhow::{anyhow, Result}; use clap::Parser; use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Semaphore; + +/// Maximum number of async rules that may run concurrently. +pub const MAX_CONCURRENT_ASYNC_RULES: usize = 10; #[derive(Debug, Parser)] pub struct HandleCommand { @@ -25,40 +31,209 @@ impl CliCommand for HandleCommand { let config = context.configuration(); println!("{}", hook_display(&self.hook, config.files.clone())); - match config.hooks.get(&self.hook) { - Some(rules) => { - let results = rules - .iter() - .map(|r| r.check_rule(context)) - .collect::>>()?; - - for rule in &results { - match rule { - RuleResult::Success { name, output } => { - println!("{name} executed successfully"); - if let Some(value) = output - && !value.is_empty() - { - println!("{value}"); - } - } - RuleResult::Failure { message, name } => { - eprintln!("{name}: {message}"); - } - RuleResult::Skipped { name } => { - println!("{name}: skipped"); + let Some(rules) = config.hooks.get(&self.hook) else { + println!("No rules found for hook {}", self.hook); + return Ok(()); + }; + + // ── Phase 1: prepare (sequential, needs &mut ctx) ────────────────────── + // + // For each rule we call `extend()` (which needs exclusive access to the + // context) and evaluate the `when` condition. After this loop we only + // need shared, immutable access to the context. + + struct PreparedMeta { + /// Pre-extended context for rules that declare their own `extract`. + /// `None` means use the shared original context. + extended_ctx: Option>, + should_skip: bool, + mode: ExecutionMode, + typetag_name: &'static str, + } + + let mut meta_vec: Vec = Vec::with_capacity(rules.len()); + + for rule_ctx in rules { + let extended_ctx = rule_ctx + .extract + .as_ref() + .map(|e| context.extend(e)) + .transpose()?; + + let actual_ctx: &dyn Context = match &extended_ctx { + Some(b) => b.as_ref(), + None => context as &dyn Context, + }; + + let should_skip = if let Some(when) = &rule_ctx.when { + !when.check(actual_ctx.variables())? + } else { + false + }; + + meta_vec.push(PreparedMeta { + extended_ctx, + should_skip, + mode: rule_ctx.rule.execution_mode(), + typetag_name: rule_ctx.rule.typetag_name(), + }); + } + + // ── Phase 2: execute (sync sequential + async concurrent, in parallel) ─ + // + // After phase 1 we only need `&dyn Context` (immutable). + // + // We share `meta` via Arc so that both the sync thread and the N async + // threads can own a clone. `ctx_ref: &dyn Context` and + // `rules: &[RuleContext]` are fat-pointer references and implement + // `Copy + Send` (because `Context: Sync` and `RuleContext: Sync`), so + // they are implicitly copied into every `move` closure. + // + // Results carry their original index so we can restore the declaration + // order before printing / error-checking. + + // Immutable borrows valid for the entire scope of this function. + let ctx_ref: &dyn Context = context; + let rules_ref: &[_] = rules; + + // A multi-thread runtime backs the semaphore. Scoped OS threads + // acquire permits via `Handle::block_on`, which is safe to call from + // non-tokio threads. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| anyhow!("failed to build tokio runtime: {e}"))?; + let rt_handle = rt.handle().clone(); + + let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_ASYNC_RULES)); + + // Wrap meta in Arc so each scoped thread can hold an independent owner. + let meta = Arc::new(meta_vec); + + // Partition rule indices by execution mode. + let mut sync_indices: Vec = Vec::new(); + let mut async_indices: Vec = Vec::new(); + for (i, m) in meta.iter().enumerate() { + match m.mode { + ExecutionMode::Sync => sync_indices.push(i), + ExecutionMode::Async => async_indices.push(i), + } + } + + let indexed_results: Vec<(usize, RuleResult)> = + std::thread::scope(|s| -> Result> { + // ── Sync thread ──────────────────────────────────────────────── + // Spawned first so it starts running immediately while async + // threads are being set up. + let meta_for_sync = Arc::clone(&meta); + let sync_handle = s.spawn(move || -> Result> { + let mut results = Vec::with_capacity(sync_indices.len()); + for &idx in &sync_indices { + let m = &meta_for_sync[idx]; + if m.should_skip { + results.push(( + idx, + RuleResult::Skipped { + name: m.typetag_name.to_string(), + }, + )); + continue; } + let actual: &dyn Context = match &m.extended_ctx { + Some(b) => b.as_ref(), + None => ctx_ref, + }; + results.push((idx, rules_ref[idx].rule.check(actual)?)); } - } + Ok(results) + }); - if results + // ── Async threads ────────────────────────────────────────────── + // One thread per async rule; they race for semaphore permits. + let async_handles: Vec<_> = async_indices .iter() - .any(|r| matches!(r, RuleResult::Failure { .. })) - { - return Err(anyhow!("Hook failed")); + .copied() + .map(|idx| { + let sem = Arc::clone(&semaphore); + let handle = rt_handle.clone(); + let meta_for_async = Arc::clone(&meta); + s.spawn(move || -> Result<(usize, RuleResult)> { + // Block the current OS thread until a concurrency slot + // is available. `Handle::block_on` drives the future + // on this thread and is safe from non-tokio threads. + let _permit = handle + .block_on(sem.acquire()) + .map_err(|_| anyhow!("semaphore closed unexpectedly"))?; + + let m = &meta_for_async[idx]; + if m.should_skip { + return Ok(( + idx, + RuleResult::Skipped { + name: m.typetag_name.to_string(), + }, + )); + } + let actual: &dyn Context = match &m.extended_ctx { + Some(b) => b.as_ref(), + None => ctx_ref, + }; + Ok((idx, rules_ref[idx].rule.check(actual)?)) + }) + }) + .collect(); + + // ── Collect results ──────────────────────────────────────────── + // Joining blocks until each thread finishes. Both groups were + // running in parallel while the threads were alive. + let mut all: Vec<(usize, RuleResult)> = Vec::with_capacity(rules_ref.len()); + + all.extend( + sync_handle + .join() + .map_err(|_| anyhow!("sync thread panicked")) + .and_then(|r| r)?, + ); + + for handle in async_handles { + all.push( + handle + .join() + .map_err(|_| anyhow!("async rule thread panicked")) + .and_then(|r| r)?, + ); + } + + // Restore original declaration order. + all.sort_unstable_by_key(|(i, _)| *i); + Ok(all) + })?; + + // ── Phase 3: report ──────────────────────────────────────────────────── + + let mut has_failure = false; + for (_, result) in &indexed_results { + match result { + RuleResult::Success { name, output } => { + println!("{name} executed successfully"); + if let Some(value) = output + && !value.is_empty() + { + println!("{value}"); + } + } + RuleResult::Failure { message, name } => { + eprintln!("{name}: {message}"); + has_failure = true; + } + RuleResult::Skipped { name } => { + println!("{name}: skipped"); } } - None => println!("No rules found for hook {}", self.hook), + } + + if has_failure { + return Err(anyhow!("Hook failed")); } Ok(()) @@ -75,6 +250,8 @@ mod tests { use std::fmt::Display; use std::sync::Arc; + // ── helpers ─────────────────────────────────────────────────────────────── + #[derive(Debug, Serialize, Deserialize)] struct FakeRule { success: bool, @@ -107,16 +284,145 @@ mod tests { } fn typetag_name(&self) -> &'static str { + "fake-rule" + } + + fn typetag_deserialize(&self) { todo!() } + } + + /// An async (Async-mode) counterpart to FakeRule. + #[derive(Debug, Serialize, Deserialize)] + struct FakeAsyncRule { + success: bool, + } + + impl FakeAsyncRule { + fn new(success: bool) -> Self { + Self { success } + } + } + + impl Display for FakeAsyncRule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FakeAsyncRule") + } + } + + impl Rule for FakeAsyncRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + + fn check(&self, _: &dyn Context) -> Result { + match self.success { + true => Ok(RuleResult::Success { + name: "FakeAsyncRule".into(), + output: None, + }), + false => Ok(RuleResult::Failure { + name: "FakeAsyncRule".into(), + message: "FakeAsyncRule failed".into(), + }), + } + } + + fn typetag_name(&self) -> &'static str { + "fake-async-rule" + } fn typetag_deserialize(&self) { todo!() } } + fn make_config(rules: Vec) -> Arc { + let mut config = Configuration { + hooks: Default::default(), + extract: vec![], + files: vec![], + }; + config.hooks.insert(GitHook::PreCommit, rules); + Arc::new(config) + } + + fn rule_ctx(rule: impl Rule + 'static) -> RuleContext { + RuleContext { + when: None, + extract: None, + rule: Box::new(rule), + } + } + + // ── sync-only rules ─────────────────────────────────────────────────────── + + #[test] + fn test_sync_rule_success() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context + .expect_configuration() + .returning(move || make_config(vec![rule_ctx(FakeRule::new(true))])); + + assert!(command.exec(&mut context).is_ok()); + } + #[test] - fn test_run() { + fn test_sync_rule_failure() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context + .expect_configuration() + .returning(move || make_config(vec![rule_ctx(FakeRule::new(false))])); + + assert!(command.exec(&mut context).is_err()); + } + + // ── async-mode rules ────────────────────────────────────────────────────── + + #[test] + fn test_async_rule_success() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context + .expect_configuration() + .returning(move || make_config(vec![rule_ctx(FakeAsyncRule::new(true))])); + + assert!(command.exec(&mut context).is_ok()); + } + + #[test] + fn test_async_rule_failure() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context + .expect_configuration() + .returning(move || make_config(vec![rule_ctx(FakeAsyncRule::new(false))])); + + assert!(command.exec(&mut context).is_err()); + } + + // ── mixed sync + async ──────────────────────────────────────────────────── + + #[test] + fn test_mixed_rules_all_succeed() { let command = HandleCommand { hook: GitHook::PreCommit, message: None, @@ -124,58 +430,146 @@ mod tests { let mut context = MockContext::new(); context.expect_configuration().returning(move || { - let mut config = Configuration { - hooks: Default::default(), - extract: vec![], - files: vec![], - }; + make_config(vec![ + rule_ctx(FakeRule::new(true)), + rule_ctx(FakeAsyncRule::new(true)), + rule_ctx(FakeRule::new(true)), + rule_ctx(FakeAsyncRule::new(true)), + ]) + }); + + assert!(command.exec(&mut context).is_ok()); + } - config.hooks.insert( - GitHook::PreCommit, - vec![RuleContext { - when: None, - extract: None, - rule: Box::new(FakeRule::new(true)), - }], - ); + #[test] + fn test_mixed_rules_sync_fails() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; - Arc::new(config) + let mut context = MockContext::new(); + context.expect_configuration().returning(move || { + make_config(vec![ + rule_ctx(FakeRule::new(false)), // sync failure + rule_ctx(FakeAsyncRule::new(true)), // async ok + ]) }); - let result = command.exec(&mut context); + assert!(command.exec(&mut context).is_err()); + } + + #[test] + fn test_mixed_rules_async_fails() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; - assert!(result.is_ok()); + let mut context = MockContext::new(); + context.expect_configuration().returning(move || { + make_config(vec![ + rule_ctx(FakeRule::new(true)), // sync ok + rule_ctx(FakeAsyncRule::new(false)), // async failure + ]) + }); + + assert!(command.exec(&mut context).is_err()); } + // ── concurrency limiting ────────────────────────────────────────────────── + #[test] - fn test_run_with_message() { + fn test_more_async_rules_than_concurrency_limit() { + // Spawn more async rules than MAX_CONCURRENT_ASYNC_RULES to exercise + // the semaphore path and ensure no deadlock. let command = HandleCommand { hook: GitHook::PreCommit, message: None, }; + let count = MAX_CONCURRENT_ASYNC_RULES * 2; let mut context = MockContext::new(); context.expect_configuration().returning(move || { - let mut config = Configuration { - hooks: Default::default(), - extract: vec![], - files: vec![], - }; + let rules = (0..count) + .map(|_| rule_ctx(FakeAsyncRule::new(true))) + .collect(); + make_config(rules) + }); + + assert!(command.exec(&mut context).is_ok()); + } - config.hooks.insert( - GitHook::PreCommit, - vec![RuleContext { - when: None, - extract: None, - rule: Box::new(FakeRule::new(false)), - }], - ); + // ── result ordering ─────────────────────────────────────────────────────── - Arc::new(config) + /// Verify that results are collected in declaration order even though async + /// rules finish in non-deterministic order. + #[test] + fn test_result_order_preserved() { + #[derive(Debug, Serialize, Deserialize)] + struct OrderedRule { + label: String, + is_async: bool, + } + + impl Display for OrderedRule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "OrderedRule({})", self.label) + } + } + + impl Rule for OrderedRule { + fn execution_mode(&self) -> ExecutionMode { + if self.is_async { ExecutionMode::Async } else { ExecutionMode::Sync } + } + + fn check(&self, _: &dyn Context) -> Result { + Ok(RuleResult::Success { + name: self.label.clone(), + output: None, + }) + } + + fn typetag_name(&self) -> &'static str { "ordered-rule" } + fn typetag_deserialize(&self) { todo!() } + } + + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context.expect_configuration().returning(move || { + make_config(vec![ + rule_ctx(OrderedRule { label: "rule-0".into(), is_async: true }), + rule_ctx(OrderedRule { label: "rule-1".into(), is_async: false }), + rule_ctx(OrderedRule { label: "rule-2".into(), is_async: true }), + rule_ctx(OrderedRule { label: "rule-3".into(), is_async: false }), + ]) }); - let result = command.exec(&mut context); + assert!(command.exec(&mut context).is_ok()); + } + + // ── no rules for hook ───────────────────────────────────────────────────── + + #[test] + fn test_no_rules_for_hook() { + let command = HandleCommand { + hook: GitHook::PreCommit, + message: None, + }; + + let mut context = MockContext::new(); + context.expect_configuration().returning(move || { + Arc::new(Configuration { + hooks: Default::default(), + extract: vec![], + files: vec![], + }) + }); - assert!(result.is_err()); + assert!(command.exec(&mut context).is_ok()); } } diff --git a/fisherman_core/src/rules/copy_files.rs b/fisherman_core/src/rules/copy_files.rs index ebda1af0..88f8acce 100644 --- a/fisherman_core/src/rules/copy_files.rs +++ b/fisherman_core/src/rules/copy_files.rs @@ -1,5 +1,5 @@ use crate::context::Context; -use crate::rules::{Rule, RuleResult}; +use crate::rules::{ExecutionMode, Rule, RuleResult}; use crate::templates::TemplateString; use anyhow::{bail, Result}; use glob::glob; @@ -44,6 +44,10 @@ fn ensure_parent_exists(path: &Path) -> Result<()> { #[typetag::serde(name = "copy-files")] impl Rule for CopyFilesRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + fn check(&self, ctx: &dyn Context) -> Result { let variables = ctx.variables()?; let compiled_glob = self.glob.compile(&variables)?; diff --git a/fisherman_core/src/rules/delete_files.rs b/fisherman_core/src/rules/delete_files.rs index 093eb44f..5684cd31 100644 --- a/fisherman_core/src/rules/delete_files.rs +++ b/fisherman_core/src/rules/delete_files.rs @@ -1,5 +1,5 @@ use crate::context::Context; -use crate::rules::{Rule, RuleResult}; +use crate::rules::{ExecutionMode, Rule, RuleResult}; use crate::templates::TemplateString; use anyhow::{bail, Result}; use glob::{glob, GlobResult}; @@ -21,6 +21,10 @@ impl std::fmt::Display for DeleteFilesRule { #[typetag::serde(name = "delete-files")] impl Rule for DeleteFilesRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + fn check(&self, ctx: &dyn Context) -> Result { let variables = ctx.variables()?; let glob_pattern = self.glob.compile(&variables)?; diff --git a/fisherman_core/src/rules/exec_rule.rs b/fisherman_core/src/rules/exec_rule.rs index 7cceaad3..fd3c7b44 100644 --- a/fisherman_core/src/rules/exec_rule.rs +++ b/fisherman_core/src/rules/exec_rule.rs @@ -1,5 +1,5 @@ use crate::context::Context; -use crate::rules::{Rule, RuleResult}; +use crate::rules::{ExecutionMode, Rule, RuleResult}; use crate::templates::{replace_in_hashmap, replace_in_vec}; use anyhow::Result; use std::collections::HashMap; @@ -30,6 +30,10 @@ impl std::fmt::Display for ExecRule { #[typetag::serde(name = "exec")] impl Rule for ExecRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + fn check(&self, ctx: &dyn Context) -> Result { let variables = ctx.variables()?; let mut env_map: Env = env::vars().collect(); diff --git a/fisherman_core/src/rules/mod.rs b/fisherman_core/src/rules/mod.rs index f4deb696..d922f09a 100644 --- a/fisherman_core/src/rules/mod.rs +++ b/fisherman_core/src/rules/mod.rs @@ -23,7 +23,7 @@ pub use commit_message_suffix::CommitMessageSuffixRule; pub use copy_files::CopyFilesRule; pub use delete_files::DeleteFilesRule; pub use exec_rule::ExecRule; -pub use rule::{Rule, RuleContext, RuleResult}; +pub use rule::{ExecutionMode, Rule, RuleContext, RuleResult}; pub use shell_script::ShellScriptRule; pub use suppress_files::SuppressFilesRule; pub use suppress_string::SuppressStringRule; diff --git a/fisherman_core/src/rules/rule.rs b/fisherman_core/src/rules/rule.rs index c506e301..c5c4e427 100644 --- a/fisherman_core/src/rules/rule.rs +++ b/fisherman_core/src/rules/rule.rs @@ -4,6 +4,19 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::fmt::Display; +/// Determines how a rule is scheduled during hook execution. +/// +/// Sync rules run sequentially in a single thread. Async rules run +/// concurrently in a thread pool (bounded by [`MAX_CONCURRENT_ASYNC_RULES`]). +/// Both groups execute in parallel with each other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionMode { + /// Run sequentially in the caller's thread, one after another. + Sync, + /// Run concurrently in a dedicated thread, limited by the global semaphore. + Async, +} + #[derive(Debug)] pub enum RuleResult { Success { @@ -22,6 +35,15 @@ pub enum RuleResult { #[typetag::serde(tag = "type")] pub trait Rule: Send + Sync + Display { fn check(&self, ctx: &dyn Context) -> Result; + + /// Returns how this rule should be scheduled at execution time. + /// + /// The default is [`ExecutionMode::Sync`]. Override to return + /// [`ExecutionMode::Async`] for rules that perform file I/O or + /// run external processes and can safely run in parallel. + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Sync + } } #[derive(Serialize, Deserialize)] @@ -34,24 +56,26 @@ pub struct RuleContext { impl RuleContext { pub fn check_rule(&self, ctx: &mut dyn Context) -> Result { - let extended = self.extract.as_ref().map(|e| ctx.extend(e)).transpose()?; - let correct_ctx: &dyn Context = match &extended { + let extended = self.extract.as_ref() + .map(|e| ctx.extend(e)) + .transpose()?; + + let actual_ctx: &dyn Context = match extended.as_ref() { Some(boxed) => boxed.as_ref(), None => ctx, }; - if self.when.is_some() && !self.check_condition(correct_ctx)? { + if self.when.is_some() && !self.check_condition(actual_ctx)? { return Ok(RuleResult::Skipped { name: self.rule.typetag_name().into(), }); } - self.rule.check(correct_ctx) + self.rule.check(actual_ctx) } fn check_condition(&self, ctx: &dyn Context) -> Result { - self.when - .as_ref() + self.when.as_ref() .map(|expr| expr.check(ctx.variables())) .unwrap_or(Ok(false)) } @@ -61,7 +85,12 @@ impl RuleContext { mod tests { use super::*; use crate::context::{Context, MockContext}; - use crate::rules::BranchNamePrefixRule; + use crate::rules::{ + BranchNamePrefixRule, BranchNameRegexRule, BranchNameSuffixRule, + CommitMessagePrefixRule, CommitMessageRegexRule, CommitMessageSuffixRule, + CopyFilesRule, DeleteFilesRule, ExecRule, ShellScriptRule, + SuppressFilesRule, SuppressStringRule, WriteFileRule, + }; use crate::scripting::Expression; use crate::t; use std::collections::HashMap; @@ -222,4 +251,80 @@ mod tests { Ok(()) } + + // ── ExecutionMode default and overrides ─────────────────────────────────── + + #[test] + fn sync_rules_default_to_sync_mode() { + assert_eq!( + BranchNamePrefixRule { prefix: t!("feat/") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + BranchNameSuffixRule { suffix: t!("-ok") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + BranchNameRegexRule { expression: t!(".*") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + CommitMessagePrefixRule { prefix: t!("fix:") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + CommitMessageSuffixRule { suffix: t!(".") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + CommitMessageRegexRule { expression: t!(".*"), when: None }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + SuppressFilesRule { glob: t!("*.log") }.execution_mode(), + ExecutionMode::Sync + ); + assert_eq!( + SuppressStringRule { regex: t!("TODO"), glob: None }.execution_mode(), + ExecutionMode::Sync + ); + } + + #[test] + fn async_rules_return_async_mode() { + assert_eq!( + ExecRule { command: "echo".into(), args: None, env: None }.execution_mode(), + ExecutionMode::Async + ); + assert_eq!( + ShellScriptRule { script: t!("echo hi"), env: None }.execution_mode(), + ExecutionMode::Async + ); + assert_eq!( + WriteFileRule { + path: t!("out.txt"), + content: t!("hello"), + append: None, + } + .execution_mode(), + ExecutionMode::Async + ); + assert_eq!( + CopyFilesRule { + glob: t!("*.txt"), + src: None, + destination: t!("dst/"), + } + .execution_mode(), + ExecutionMode::Async + ); + assert_eq!( + DeleteFilesRule { + glob: t!("*.tmp"), + fail_if_not_found: false, + } + .execution_mode(), + ExecutionMode::Async + ); + } } diff --git a/fisherman_core/src/rules/shell_script.rs b/fisherman_core/src/rules/shell_script.rs index c13eeba7..86dcc81b 100644 --- a/fisherman_core/src/rules/shell_script.rs +++ b/fisherman_core/src/rules/shell_script.rs @@ -1,5 +1,5 @@ use crate::context::Context; -use crate::rules::{Rule, RuleResult}; +use crate::rules::{ExecutionMode, Rule, RuleResult}; use crate::templates::TemplateString; use anyhow::Result; use run_script::{run, ScriptOptions}; @@ -22,6 +22,10 @@ static SHELL_SCRIPT_NAME: &str = "shell"; #[typetag::serde(name = "shell")] impl Rule for ShellScriptRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + fn check(&self, ctx: &dyn Context) -> Result { let mut options = ScriptOptions::new(); options.env_vars = self.env.clone(); diff --git a/fisherman_core/src/rules/write_file.rs b/fisherman_core/src/rules/write_file.rs index cedc0717..d24b26ad 100644 --- a/fisherman_core/src/rules/write_file.rs +++ b/fisherman_core/src/rules/write_file.rs @@ -1,5 +1,5 @@ use crate::context::Context; -use crate::rules::{Rule, RuleResult}; +use crate::rules::{ExecutionMode, Rule, RuleResult}; use crate::templates::TemplateString; use anyhow::Result; use std::fs::OpenOptions; @@ -20,6 +20,10 @@ impl std::fmt::Display for WriteFileRule { #[typetag::serde(name = "write-file")] impl Rule for WriteFileRule { + fn execution_mode(&self) -> ExecutionMode { + ExecutionMode::Async + } + fn check(&self, ctx: &dyn Context) -> Result { let variables = ctx.variables()?; let path = self.path.compile(&variables)?;