@@ -66,16 +66,51 @@ fn parse_post_tool_use(raw: &Value) -> Option<(EventKind, Value, Vec<PathBuf>)>
6666 Some ( ( EventKind :: FileEdited , payload, edited) )
6767 } else if tool_name == "Bash" {
6868 let command = input_str ( "command" ) ?;
69+ let kind = if is_test_command ( & command) {
70+ EventKind :: TestsRun
71+ } else {
72+ EventKind :: CommandRun
73+ } ;
6974 let payload = serde_json:: json!( {
7075 "command" : command,
7176 "description" : input_str( "description" ) ,
7277 } ) ;
73- Some ( ( EventKind :: CommandRun , payload, vec ! [ ] ) )
78+ Some ( ( kind , payload, vec ! [ ] ) )
7479 } else {
7580 None
7681 }
7782}
7883
84+ /// Test-runner detection: standalone runners or `<tool> test` invocations,
85+ /// anywhere in a compound command line.
86+ fn is_test_command ( command : & str ) -> bool {
87+ const STANDALONE : [ & str ; 8 ] = [
88+ "pytest" , "jest" , "vitest" , "mocha" , "rspec" , "phpunit" , "tox" , "ctest" ,
89+ ] ;
90+ const TEST_SUBCOMMAND : [ & str ; 9 ] = [
91+ "cargo" , "go" , "npm" , "pnpm" , "yarn" , "bun" , "dotnet" , "mix" , "mvn" ,
92+ ] ;
93+ let tokens: Vec < & str > = command
94+ . split ( |c : char | c. is_whitespace ( ) || matches ! ( c, ';' | '(' | ')' ) )
95+ . filter ( |t| !t. is_empty ( ) && * t != "&&" && * t != "||" )
96+ . collect ( ) ;
97+ tokens. iter ( ) . enumerate ( ) . any ( |( i, tok) | {
98+ let base = tok. rsplit ( '/' ) . next ( ) . unwrap_or ( tok) ;
99+ if STANDALONE . contains ( & base) {
100+ return true ;
101+ }
102+ if base == "gradle" || base == "gradlew" {
103+ return tokens. get ( i + 1 ) . is_some_and ( |next| * next == "test" ) ;
104+ }
105+ TEST_SUBCOMMAND . contains ( & base)
106+ && tokens. get ( i + 1 ) . is_some_and ( |next| {
107+ * next == "test"
108+ || * next == "nextest"
109+ || ( * next == "run" && tokens. get ( i + 2 ) . is_some_and ( |n| n. starts_with ( "test" ) ) )
110+ } )
111+ } )
112+ }
113+
79114#[ cfg( test) ]
80115mod tests {
81116 use super :: * ;
@@ -113,15 +148,59 @@ mod tests {
113148 let raw = serde_json:: json!( {
114149 "hook_event_name" : "PostToolUse" ,
115150 "tool_name" : "Bash" ,
116- "tool_input" : { "command" : "cargo test " , "description" : "Run tests " }
151+ "tool_input" : { "command" : "cargo build " , "description" : "Build " }
117152 } ) ;
118153 let parsed = parse_hook_event ( & raw ) . unwrap ( ) ;
119154 assert_eq ! ( parsed. kind, EventKind :: CommandRun ) ;
120- assert_eq ! ( parsed. payload[ "command" ] , "cargo test " ) ;
121- assert_eq ! ( parsed. payload[ "description" ] , "Run tests " ) ;
155+ assert_eq ! ( parsed. payload[ "command" ] , "cargo build " ) ;
156+ assert_eq ! ( parsed. payload[ "description" ] , "Build " ) ;
122157 assert ! ( parsed. edited_paths. is_empty( ) ) ;
123158 }
124159
160+ #[ test]
161+ fn bash_test_commands_map_to_tests_run ( ) {
162+ for cmd in [
163+ "cargo test" ,
164+ "cargo nextest run" ,
165+ "cd api && cargo test -p forge-core" ,
166+ "npm test" ,
167+ "npm run test:unit" ,
168+ "pnpm test --filter web" ,
169+ "go test ./..." ,
170+ "python -m pytest tests/" ,
171+ "./node_modules/.bin/vitest run" ,
172+ "./gradlew test" ,
173+ ] {
174+ let raw = serde_json:: json!( {
175+ "hook_event_name" : "PostToolUse" ,
176+ "tool_name" : "Bash" ,
177+ "tool_input" : { "command" : cmd }
178+ } ) ;
179+ let parsed = parse_hook_event ( & raw ) . unwrap ( ) ;
180+ assert_eq ! ( parsed. kind, EventKind :: TestsRun , "command: {cmd}" ) ;
181+ assert_eq ! ( parsed. payload[ "command" ] , cmd) ;
182+ }
183+ }
184+
185+ #[ test]
186+ fn bash_non_test_commands_stay_command_run ( ) {
187+ for cmd in [
188+ "cargo build" ,
189+ "npm run dev" ,
190+ "go build ./..." ,
191+ "ls tests/" ,
192+ "git commit -m 'test: add coverage'" ,
193+ ] {
194+ let raw = serde_json:: json!( {
195+ "hook_event_name" : "PostToolUse" ,
196+ "tool_name" : "Bash" ,
197+ "tool_input" : { "command" : cmd }
198+ } ) ;
199+ let parsed = parse_hook_event ( & raw ) . unwrap ( ) ;
200+ assert_eq ! ( parsed. kind, EventKind :: CommandRun , "command: {cmd}" ) ;
201+ }
202+ }
203+
125204 #[ test]
126205 fn stop_and_session_start_map_to_session_events ( ) {
127206 let stop = serde_json:: json!( { "hook_event_name" : "Stop" , "session_id" : "s" } ) ;
0 commit comments