Skip to main content

rec/hooks/
scripts.rs

1use crate::cli::Shell;
2
3/// Get the hook script for the specified shell.
4#[must_use]
5pub fn get_hook_script(shell: Shell) -> &'static str {
6    match shell {
7        Shell::Bash => BASH_HOOK,
8        Shell::Zsh => ZSH_HOOK,
9        Shell::Fish => FISH_HOOK,
10    }
11}
12
13/// Get the shell initialization command to add to rc file.
14#[must_use]
15pub fn get_init_command(shell: Shell) -> &'static str {
16    match shell {
17        Shell::Bash => r#"eval "$(rec init bash)""#,
18        Shell::Zsh => r#"eval "$(rec init zsh)""#,
19        Shell::Fish => r"rec init fish | source",
20    }
21}
22
23/// Bash hook script.
24///
25/// Requires bash-preexec to be sourced first. Registers preexec/precmd
26/// functions that call rec _hook for command capture.
27pub const BASH_HOOK: &str = r#"
28# rec shell hooks for Bash
29# Add to ~/.bashrc: eval "$(rec init bash)"
30
31# Shell wrapper: automatically manages REC_RECORDING for start/stop
32rec() {
33    if [[ "${1:-}" == "start" ]]; then
34        command rec "$@" && export REC_RECORDING=1
35    elif [[ "${1:-}" == "stop" ]]; then
36        command rec "$@" && unset REC_RECORDING
37    else
38        command rec "$@"
39    fi
40}
41
42# Source bash-preexec if not already loaded
43if [[ -z "${bash_preexec_imported:-}" ]]; then
44    # bash-preexec is output first by rec init bash
45    :
46fi
47
48# Capture command before execution
49__rec_preexec() {
50    # $1 is the command (aliases expanded by bash-preexec)
51    if [[ -n "${REC_RECORDING:-}" ]]; then
52        rec _hook preexec "$1" 2>/dev/null || true
53    fi
54}
55
56# Capture exit code after command completes
57__rec_precmd() {
58    local exit_code=$?
59    if [[ -n "${REC_RECORDING:-}" ]]; then
60        rec _hook precmd "$exit_code" 2>/dev/null || true
61    fi
62}
63
64# Register hooks with bash-preexec
65preexec_functions+=(__rec_preexec)
66precmd_functions+=(__rec_precmd)
67
68# Recording indicator for prompt (optional, disable with REC_NO_PROMPT=1)
69__rec_prompt_indicator() {
70    if [[ -n "${REC_RECORDING:-}" && -z "${REC_NO_PROMPT:-}" ]]; then
71        printf '\[\e[31m\]● \[\e[0m\]'
72    fi
73}
74
75# Prepend indicator to PS1 if not disabled
76if [[ -z "${REC_NO_PROMPT:-}" ]]; then
77    PS1='$(__rec_prompt_indicator)'"${PS1}"
78fi
79"#;
80
81/// Zsh hook script.
82///
83/// Uses native Zsh hooks via add-zsh-hook. Note: $3 in preexec is the
84/// fully expanded command (aliases expanded), while $1 is what was typed.
85pub const ZSH_HOOK: &str = r#"
86# rec shell hooks for Zsh
87# Add to ~/.zshrc: eval "$(rec init zsh)"
88
89# Shell wrapper: automatically manages REC_RECORDING for start/stop
90rec() {
91    if [[ "${1:-}" == "start" ]]; then
92        command rec "$@" && export REC_RECORDING=1
93    elif [[ "${1:-}" == "stop" ]]; then
94        command rec "$@" && unset REC_RECORDING
95    else
96        command rec "$@"
97    fi
98}
99
100autoload -Uz add-zsh-hook
101
102# Capture command before execution
103# $1 = typed command, $2 = expanded aliases only, $3 = full expansion
104__rec_preexec() {
105    if [[ -n "${REC_RECORDING:-}" ]]; then
106        rec _hook preexec "$3" 2>/dev/null || true
107    fi
108}
109
110# Capture exit code after command completes
111__rec_precmd() {
112    local exit_code=$?
113    if [[ -n "${REC_RECORDING:-}" ]]; then
114        rec _hook precmd "$exit_code" 2>/dev/null || true
115    fi
116}
117
118# Register hooks
119add-zsh-hook preexec __rec_preexec
120add-zsh-hook precmd __rec_precmd
121
122# Recording indicator for prompt (optional, disable with REC_NO_PROMPT=1)
123__rec_prompt_indicator() {
124    if [[ -n "${REC_RECORDING:-}" && -z "${REC_NO_PROMPT:-}" ]]; then
125        print -n '%{\e[31m%}● %{\e[0m%}'
126    fi
127}
128
129# Prepend indicator to PROMPT if not disabled
130if [[ -z "${REC_NO_PROMPT:-}" ]]; then
131    PROMPT='$(__rec_prompt_indicator)'"${PROMPT}"
132fi
133"#;
134
135/// Fish hook script.
136///
137/// Uses native Fish event handlers. Note: Fish does NOT expand aliases
138/// in `fish_preexec` - the command line is passed verbatim.
139pub const FISH_HOOK: &str = r#"
140# rec shell hooks for Fish
141# Add to ~/.config/fish/config.fish: rec init fish | source
142
143# Shell wrapper: automatically manages REC_RECORDING for start/stop
144function rec --wraps=rec
145    if test (count $argv) -ge 1; and test "$argv[1]" = "start"
146        command rec $argv; and set -gx REC_RECORDING 1
147    else if test (count $argv) -ge 1; and test "$argv[1]" = "stop"
148        command rec $argv; and set -e REC_RECORDING
149    else
150        command rec $argv
151    end
152end
153
154# Capture command before execution
155# Note: $argv is the verbatim command line (aliases NOT expanded)
156function __rec_preexec --on-event fish_preexec
157    if set -q REC_RECORDING
158        rec _hook preexec "$argv" 2>/dev/null; or true
159    end
160end
161
162# Capture exit code after command completes
163function __rec_postexec --on-event fish_postexec
164    set -l exit_code $status
165    if set -q REC_RECORDING
166        rec _hook precmd "$exit_code" 2>/dev/null; or true
167    end
168end
169
170# Recording indicator for prompt
171# Call this from your fish_prompt function if you want the indicator
172function __rec_prompt_indicator
173    if set -q REC_RECORDING; and not set -q REC_NO_PROMPT
174        set_color red
175        echo -n "● "
176        set_color normal
177    end
178end
179
180# Fish doesn't allow dynamic prompt modification like Bash/Zsh
181# Users should add __rec_prompt_indicator to their fish_prompt function
182# Or we provide a wrapper (below) that they can use
183
184# Optional: Wrap existing fish_prompt to add indicator
185# Uncomment if you want automatic prompt modification:
186# functions -c fish_prompt __rec_original_fish_prompt 2>/dev/null
187# function fish_prompt
188#     __rec_prompt_indicator
189#     __rec_original_fish_prompt
190# end
191"#;
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_bash_hook_contains_preexec() {
199        assert!(BASH_HOOK.contains("__rec_preexec"));
200        assert!(BASH_HOOK.contains("rec _hook preexec"));
201        assert!(BASH_HOOK.contains("preexec_functions"));
202    }
203
204    #[test]
205    fn test_bash_hook_contains_precmd() {
206        assert!(BASH_HOOK.contains("__rec_precmd"));
207        assert!(BASH_HOOK.contains("rec _hook precmd"));
208        assert!(BASH_HOOK.contains("precmd_functions"));
209    }
210
211    #[test]
212    fn test_bash_hook_checks_rec_recording() {
213        assert!(BASH_HOOK.contains("REC_RECORDING"));
214    }
215
216    #[test]
217    fn test_bash_hook_suppresses_errors() {
218        assert!(BASH_HOOK.contains("2>/dev/null || true"));
219    }
220
221    #[test]
222    fn test_bash_hook_has_prompt_indicator() {
223        assert!(BASH_HOOK.contains("__rec_prompt_indicator"));
224        // Bash uses \[\e[31m\] for escape sequences
225        assert!(BASH_HOOK.contains(r"\[\e[31m\]"));
226    }
227
228    #[test]
229    fn test_zsh_hook_uses_add_zsh_hook() {
230        assert!(ZSH_HOOK.contains("add-zsh-hook"));
231        assert!(ZSH_HOOK.contains("add-zsh-hook preexec __rec_preexec"));
232        assert!(ZSH_HOOK.contains("add-zsh-hook precmd __rec_precmd"));
233    }
234
235    #[test]
236    fn test_zsh_hook_uses_dollar_3_for_expanded_command() {
237        // $3 is the fully expanded command in Zsh preexec
238        assert!(ZSH_HOOK.contains("\"$3\""));
239    }
240
241    #[test]
242    fn test_zsh_hook_contains_rec_hook_calls() {
243        assert!(ZSH_HOOK.contains("rec _hook preexec"));
244        assert!(ZSH_HOOK.contains("rec _hook precmd"));
245    }
246
247    #[test]
248    fn test_zsh_hook_checks_rec_recording() {
249        assert!(ZSH_HOOK.contains("REC_RECORDING"));
250    }
251
252    #[test]
253    fn test_zsh_hook_suppresses_errors() {
254        assert!(ZSH_HOOK.contains("2>/dev/null || true"));
255    }
256
257    #[test]
258    fn test_zsh_hook_has_prompt_indicator() {
259        assert!(ZSH_HOOK.contains("__rec_prompt_indicator"));
260        // Zsh uses %{\e[31m%} for escape sequences
261        assert!(ZSH_HOOK.contains(r"%{\e[31m%}"));
262    }
263
264    #[test]
265    fn test_fish_hook_uses_events() {
266        assert!(FISH_HOOK.contains("--on-event fish_preexec"));
267        assert!(FISH_HOOK.contains("--on-event fish_postexec"));
268    }
269
270    #[test]
271    fn test_fish_hook_contains_rec_hook_calls() {
272        assert!(FISH_HOOK.contains("rec _hook preexec"));
273        assert!(FISH_HOOK.contains("rec _hook precmd"));
274    }
275
276    #[test]
277    fn test_fish_hook_checks_rec_recording() {
278        assert!(FISH_HOOK.contains("REC_RECORDING"));
279    }
280
281    #[test]
282    fn test_fish_hook_suppresses_errors() {
283        assert!(FISH_HOOK.contains("2>/dev/null; or true"));
284    }
285
286    #[test]
287    fn test_fish_hook_has_prompt_indicator() {
288        assert!(FISH_HOOK.contains("__rec_prompt_indicator"));
289        assert!(FISH_HOOK.contains("set_color red"));
290    }
291
292    #[test]
293    fn test_all_hooks_check_rec_recording() {
294        assert!(BASH_HOOK.contains("REC_RECORDING"));
295        assert!(ZSH_HOOK.contains("REC_RECORDING"));
296        assert!(FISH_HOOK.contains("REC_RECORDING"));
297    }
298
299    #[test]
300    fn test_get_hook_script() {
301        assert!(get_hook_script(Shell::Bash).contains("bash-preexec"));
302        assert!(get_hook_script(Shell::Zsh).contains("add-zsh-hook"));
303        assert!(get_hook_script(Shell::Fish).contains("fish_preexec"));
304    }
305
306    #[test]
307    fn test_get_init_command() {
308        assert_eq!(get_init_command(Shell::Bash), r#"eval "$(rec init bash)""#);
309        assert_eq!(get_init_command(Shell::Zsh), r#"eval "$(rec init zsh)""#);
310        assert_eq!(get_init_command(Shell::Fish), r"rec init fish | source");
311    }
312
313    #[test]
314    fn test_hook_scripts_are_substantial() {
315        // Each hook script should have meaningful content
316        assert!(BASH_HOOK.lines().count() > 20);
317        assert!(ZSH_HOOK.lines().count() > 20);
318        assert!(FISH_HOOK.lines().count() > 20);
319
320        // Combined, scripts module should be 100+ lines
321        let total_lines =
322            BASH_HOOK.lines().count() + ZSH_HOOK.lines().count() + FISH_HOOK.lines().count();
323        assert!(
324            total_lines > 80,
325            "Expected 80+ lines of hook content, got {total_lines}"
326        );
327    }
328}