512 lines
22 KiB
Rust
512 lines
22 KiB
Rust
//! `bread hooks install shell` — generates a shell hook script that reports
|
|
//! terminal telemetry (command start/finish, cwd changes, remote-session
|
|
//! start/end) to the bread daemon.
|
|
//!
|
|
//! # Why this exists as a generated file, not an rc-file edit
|
|
//!
|
|
//! This module deliberately **never touches the user's `.bashrc`, `.zshrc`,
|
|
//! or fish `config.fish`.** It only writes self-contained hook script(s)
|
|
//! under `~/.config/bread/hooks/` and prints the one-line `source` snippet
|
|
//! the user needs to add themselves. Editing a user's shell startup file
|
|
//! automatically is the kind of silent, hard-to-audit change that belongs
|
|
//! to the user, not to a CLI subcommand — if `bread` got it wrong, or the
|
|
//! user later doesn't want it, an rc-file edit is much harder to notice and
|
|
//! undo than a printed snippet they chose to paste in.
|
|
//!
|
|
//! # Why bread-emit, not `bread emit`
|
|
//!
|
|
//! The generated hooks shell out to the separate `bread-emit` binary
|
|
//! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap
|
|
//! parsing and the Tokio runtime entirely and never waits for a reply — it
|
|
//! is cheap enough to call on every single shell prompt. The full `bread`
|
|
//! CLI spins up an async runtime per invocation and would be perceptible
|
|
//! latency if called twice per prompt.
|
|
//!
|
|
//! # Why both a `.sh` and a `.fish` file are always written
|
|
//!
|
|
//! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a
|
|
//! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n
|
|
//! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has
|
|
//! meaningfully different hook primitives and cannot source a POSIX-ish
|
|
//! script anyway), regardless of which shell was detected or requested.
|
|
//! This keeps both files present and up to date at fixed, predictable
|
|
//! paths so a user who switches shells later doesn't need to re-run
|
|
//! install — they just add the one extra `source` line to the new shell's
|
|
//! rc file. Only the *printed* snippet is specific to the detected/forced
|
|
//! shell.
|
|
//!
|
|
//! Existing hook files at these paths are always overwritten. Unlike git
|
|
//! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user
|
|
//! might have installed by other means — this file is entirely owned and
|
|
//! generated by this command, so overwriting on every run is safe and is
|
|
//! in fact required to pick up script changes across `bread` upgrades.
|
|
|
|
use anyhow::{bail, Result};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Which shell to print the `source` snippet for. Detected from `$SHELL`
|
|
/// unless the caller forces one via `bread hooks install shell --shell
|
|
/// <name>`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum TargetShell {
|
|
Bash,
|
|
Zsh,
|
|
Fish,
|
|
/// Detection failed (unrecognized or unset `$SHELL`); both snippets are
|
|
/// printed and the user picks the one that matches their shell.
|
|
Unknown,
|
|
}
|
|
|
|
impl TargetShell {
|
|
fn parse(name: &str) -> Result<TargetShell> {
|
|
match name {
|
|
"bash" => Ok(TargetShell::Bash),
|
|
"zsh" => Ok(TargetShell::Zsh),
|
|
"fish" => Ok(TargetShell::Fish),
|
|
other => bail!(
|
|
"bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)",
|
|
other
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Detect the caller's shell from `$SHELL`'s basename. This is a best
|
|
/// effort: `$SHELL` reflects the user's login shell, which is normally
|
|
/// also the shell running the CLI, but that's not guaranteed (e.g.
|
|
/// invoked from inside a script run under a different interpreter).
|
|
fn detect_from_env() -> TargetShell {
|
|
let Ok(shell_path) = std::env::var("SHELL") else {
|
|
return TargetShell::Unknown;
|
|
};
|
|
let name = PathBuf::from(shell_path)
|
|
.file_name()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
match name.as_str() {
|
|
"bash" => TargetShell::Bash,
|
|
"zsh" => TargetShell::Zsh,
|
|
"fish" => TargetShell::Fish,
|
|
_ => TargetShell::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the default hooks directory: `~/.config/bread/hooks`.
|
|
pub fn hooks_dir() -> PathBuf {
|
|
if let Some(cfg) = dirs::config_dir() {
|
|
return cfg.join("bread").join("hooks");
|
|
}
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
return PathBuf::from(home)
|
|
.join(".config")
|
|
.join("bread")
|
|
.join("hooks");
|
|
}
|
|
PathBuf::from(".config/bread/hooks")
|
|
}
|
|
|
|
/// Install (or regenerate) the shell hook scripts and print the snippet the
|
|
/// user needs to add to their own rc file. `shell` optionally forces
|
|
/// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL`
|
|
/// purely to decide which snippet to print — both hook files are written
|
|
/// either way (see module docs).
|
|
pub fn install_shell(shell: Option<String>) -> Result<()> {
|
|
let target = match shell {
|
|
Some(name) => TargetShell::parse(&name)?,
|
|
None => TargetShell::detect_from_env(),
|
|
};
|
|
|
|
let dir = hooks_dir();
|
|
fs::create_dir_all(&dir)
|
|
.map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?;
|
|
|
|
let sh_path = dir.join("shell-hook.sh");
|
|
write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?;
|
|
|
|
let fish_path = dir.join("shell-hook.fish");
|
|
write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?;
|
|
|
|
println!();
|
|
match target {
|
|
TargetShell::Bash | TargetShell::Zsh => {
|
|
println!("Add this line to your ~/.bashrc or ~/.zshrc:");
|
|
println!();
|
|
println!(" source {}", sh_path.display());
|
|
}
|
|
TargetShell::Fish => {
|
|
println!("Add this line to your ~/.config/fish/config.fish:");
|
|
println!();
|
|
println!(" source {}", fish_path.display());
|
|
}
|
|
TargetShell::Unknown => {
|
|
println!(
|
|
"bread: could not detect your shell from $SHELL; add whichever \
|
|
of these matches the shell you use interactively:"
|
|
);
|
|
println!();
|
|
println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:");
|
|
println!(" source {}", sh_path.display());
|
|
println!();
|
|
println!(" fish — add to ~/.config/fish/config.fish:");
|
|
println!(" source {}", fish_path.display());
|
|
}
|
|
}
|
|
println!();
|
|
println!(
|
|
"bread never edits rc files on its own — that line above is something \
|
|
only you should add."
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> {
|
|
let existed = path.exists();
|
|
fs::write(path, contents)
|
|
.map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?;
|
|
if existed {
|
|
println!("bread: regenerated {}", path.display());
|
|
} else {
|
|
println!("bread: wrote {}", path.display());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook script bodies
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Both scripts below shell out to `bread-emit` (not `bread emit`) for every
|
|
// event, always backgrounded and with output discarded (`>/dev/null 2>&1
|
|
// &`), so a down, slow, or stalled daemon can never block the interactive
|
|
// shell waiting for the next prompt.
|
|
//
|
|
// Event/kind pairs emitted (kept in sync with the daemon's normalizer):
|
|
// bread.terminal.command.started (kind command.started)
|
|
// bread.terminal.command.finished (kind command.finished)
|
|
// bread.terminal.cwd.changed (kind cwd.changed)
|
|
// bread.remote.session.started (kind session.started)
|
|
// bread.remote.session.ended (kind session.ended)
|
|
//
|
|
// `bread-emit`'s positional <event> argument is technically redundant once
|
|
// --source/--kind are both given (the daemon derives the real event name
|
|
// from source+kind and ignores the literal positional string in that
|
|
// mode), but a sensible name is still passed since bread-emit requires a
|
|
// positional argument.
|
|
|
|
/// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`.
|
|
///
|
|
/// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the
|
|
/// three, so its branch approximates them with a `trap ... DEBUG` (guarded
|
|
/// so it only fires once per prompt, not once per pipeline stage) and a
|
|
/// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against
|
|
/// a remembered previous value to emulate `chpwd`.
|
|
const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`.
|
|
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
|
|
#
|
|
# Reports terminal telemetry (command start/finish, cwd changes, remote
|
|
# session start/end) to the bread daemon via the lightweight `bread-emit`
|
|
# binary. Every call below is backgrounded and has its output discarded so a
|
|
# down or slow daemon can never delay the shell prompt.
|
|
#
|
|
# Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for
|
|
# the JSON-escaping helper below) — both are standard on Arch Linux.
|
|
|
|
# Escape a string for embedding inside a JSON string literal: backslashes,
|
|
# double quotes, tabs, carriage returns, and embedded newlines. Backslashes
|
|
# must be escaped first, before any escape sequence that introduces new
|
|
# backslashes, or the newly-added backslashes would themselves get doubled.
|
|
_bread_json_escape() {
|
|
printf '%s' "$1" \
|
|
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \
|
|
| sed ':a;N;$!ba;s/\n/\\n/g'
|
|
}
|
|
|
|
# --- Remote session detection (runs once at shell-init time) --------------
|
|
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
|
|
# so nested subshells (and the EXIT trap that fires only in this branch)
|
|
# don't each fire their own started/ended pair.
|
|
if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then
|
|
export BREAD_SSH_ANNOUNCED=1
|
|
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
|
|
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
|
|
_bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}')
|
|
|
|
_bread_esc_host=$(_bread_json_escape "$_bread_ssh_host")
|
|
bread-emit bread.remote.session.started --source remote --kind session.started \
|
|
--data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 &
|
|
|
|
_bread_ssh_exit_hook() {
|
|
_bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host")
|
|
bread-emit bread.remote.session.ended --source remote --kind session.ended \
|
|
--data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 &
|
|
}
|
|
trap _bread_ssh_exit_hook EXIT
|
|
fi
|
|
|
|
if [ -n "$ZSH_VERSION" ]; then
|
|
# --- zsh: preexec / precmd / chpwd are native ---------------------------
|
|
autoload -Uz add-zsh-hook
|
|
|
|
_bread_preexec() {
|
|
_bread_cmd_start=$(date +%s%3N)
|
|
_bread_cmd_cwd="$PWD"
|
|
_bread_cmd_str="$1"
|
|
local esc_cmd esc_cwd
|
|
esc_cmd=$(_bread_json_escape "$1")
|
|
esc_cwd=$(_bread_json_escape "$PWD")
|
|
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
|
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
|
|
}
|
|
add-zsh-hook preexec _bread_preexec
|
|
|
|
_bread_precmd() {
|
|
local exit_code=$?
|
|
if [ -n "$_bread_cmd_str" ]; then
|
|
local now duration_ms esc_cmd esc_cwd
|
|
now=$(date +%s%3N)
|
|
duration_ms=$(( now - _bread_cmd_start ))
|
|
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
|
|
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
|
|
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
|
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
|
|
_bread_cmd_str=""
|
|
fi
|
|
}
|
|
add-zsh-hook precmd _bread_precmd
|
|
|
|
_bread_chpwd() {
|
|
local esc_cwd esc_prev
|
|
esc_cwd=$(_bread_json_escape "$PWD")
|
|
esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}")
|
|
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
|
--data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
|
|
}
|
|
add-zsh-hook chpwd _bread_chpwd
|
|
|
|
elif [ -n "$BASH_VERSION" ]; then
|
|
# --- bash: no native preexec/precmd/chpwd; approximate them -------------
|
|
# trap ... DEBUG fires before every simple command. `_bread_preexec_guard`
|
|
# ensures only the first simple command between prompts is recorded, not
|
|
# every stage of a pipeline or every `;`-separated command on one line.
|
|
# This relies on bash's default of NOT propagating the DEBUG trap into
|
|
# function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off);
|
|
# if something in the user's environment turns those on, the guard below
|
|
# could fire more than once per prompt.
|
|
_bread_preexec() {
|
|
[ -n "$_bread_preexec_guard" ] && return
|
|
case "$BASH_COMMAND" in
|
|
_bread_precmd*|"$PROMPT_COMMAND") return ;;
|
|
esac
|
|
_bread_preexec_guard=1
|
|
_bread_cmd_start=$(date +%s%3N)
|
|
_bread_cmd_cwd="$PWD"
|
|
_bread_cmd_str="$BASH_COMMAND"
|
|
local esc_cmd esc_cwd
|
|
esc_cmd=$(_bread_json_escape "$BASH_COMMAND")
|
|
esc_cwd=$(_bread_json_escape "$PWD")
|
|
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
|
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
|
|
}
|
|
trap '_bread_preexec' DEBUG
|
|
|
|
_bread_precmd() {
|
|
local exit_code=$?
|
|
if [ -n "$_bread_preexec_guard" ]; then
|
|
local now duration_ms esc_cmd esc_cwd
|
|
now=$(date +%s%3N)
|
|
duration_ms=$(( now - _bread_cmd_start ))
|
|
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
|
|
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
|
|
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
|
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
|
|
fi
|
|
_bread_preexec_guard=""
|
|
|
|
# chpwd emulation: bash has no native hook, so compare $PWD against a
|
|
# remembered previous value on every prompt.
|
|
if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then
|
|
local esc_cwd2 esc_prev
|
|
esc_cwd2=$(_bread_json_escape "$PWD")
|
|
esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}")
|
|
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
|
--data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
|
|
_bread_prev_pwd="$PWD"
|
|
fi
|
|
}
|
|
PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
|
|
_bread_prev_pwd="$PWD"
|
|
fi
|
|
"#;
|
|
|
|
/// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`.
|
|
///
|
|
/// Fish's job-control primitives are meaningfully nicer than bash/zsh here:
|
|
/// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games),
|
|
/// `$status` in the postexec handler gives the exit code directly, and
|
|
/// `$CMD_DURATION` is already a builtin millisecond duration — no manual
|
|
/// timestamp math needed. `string escape --style=json` (fish >= 3.3) does
|
|
/// the JSON-string-literal escaping, quotes included, in one call.
|
|
const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`.
|
|
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
|
|
#
|
|
# Reports terminal telemetry (command start/finish, cwd changes, remote
|
|
# session start/end) to the bread daemon via the lightweight `bread-emit`
|
|
# binary. Every call below is backgrounded and has its output discarded so a
|
|
# down or slow daemon can never delay the shell prompt.
|
|
#
|
|
# Requires fish >= 3.3 for `string escape --style=json`.
|
|
|
|
function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)'
|
|
string escape --style=json -- $argv[1]
|
|
end
|
|
|
|
function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started'
|
|
set -g _bread_cmd_cwd $PWD
|
|
set -l cmd_json (_bread_json_escape $argv[1])
|
|
set -l cwd_json (_bread_json_escape $PWD)
|
|
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
|
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 &
|
|
end
|
|
|
|
function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished'
|
|
# $status must be captured first, before any other command in this
|
|
# function has a chance to overwrite it.
|
|
set -l exit_code $status
|
|
set -l cmd_json (_bread_json_escape $argv[1])
|
|
set -l cwd_json (_bread_json_escape $_bread_cmd_cwd)
|
|
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
|
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 &
|
|
end
|
|
|
|
function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed'
|
|
status is-interactive; or return
|
|
set -l cwd_json (_bread_json_escape $PWD)
|
|
set -l prev_json (_bread_json_escape $_bread_prev_pwd)
|
|
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
|
--data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 &
|
|
set -g _bread_prev_pwd $PWD
|
|
end
|
|
set -g _bread_prev_pwd $PWD
|
|
|
|
# --- Remote session detection (runs once at shell-init time) --------------
|
|
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
|
|
# so nested fish subshells (and the fish_exit handler, which is only
|
|
# registered in this branch) don't each fire their own started/ended pair.
|
|
set -l _bread_is_ssh 0
|
|
if test -n "$SSH_TTY"
|
|
set _bread_is_ssh 1
|
|
else if test -n "$SSH_CONNECTION"
|
|
set _bread_is_ssh 1
|
|
end
|
|
|
|
if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED"
|
|
set -gx BREAD_SSH_ANNOUNCED 1
|
|
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
|
|
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
|
|
set -l _bread_conn_str $SSH_CONNECTION
|
|
if test -z "$_bread_conn_str"
|
|
set _bread_conn_str $SSH_CLIENT
|
|
end
|
|
set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1]
|
|
|
|
set -l host_json (_bread_json_escape $_bread_ssh_host)
|
|
bread-emit bread.remote.session.started --source remote --kind session.started \
|
|
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
|
|
|
|
function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended'
|
|
set -l host_json (_bread_json_escape $_bread_ssh_host)
|
|
bread-emit bread.remote.session.ended --source remote --kind session.ended \
|
|
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
|
|
end
|
|
end
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_accepts_known_shells() {
|
|
assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash);
|
|
assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh);
|
|
assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_unknown_shell() {
|
|
assert!(TargetShell::parse("powershell").is_err());
|
|
assert!(TargetShell::parse("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn detect_from_env_reads_shell_basename() {
|
|
std::env::set_var("SHELL", "/usr/bin/zsh");
|
|
assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh);
|
|
|
|
std::env::set_var("SHELL", "/bin/bash");
|
|
assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash);
|
|
|
|
std::env::set_var("SHELL", "/usr/bin/fish");
|
|
assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish);
|
|
|
|
std::env::set_var("SHELL", "/usr/bin/tcsh");
|
|
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
|
|
|
|
std::env::remove_var("SHELL");
|
|
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_scripts_reference_bread_emit_not_bread_emit_cli() {
|
|
// Guard against accidentally shelling out to the slow `bread emit`
|
|
// subcommand instead of the lightweight `bread-emit` binary.
|
|
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
|
|
assert!(script.contains("bread-emit "));
|
|
assert!(!script.contains("bread emit "));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hook_scripts_background_every_emit_call() {
|
|
// Every bread-emit invocation must be backgrounded with output
|
|
// discarded so a stalled daemon can never block the shell. A single
|
|
// invocation can span multiple physical lines via a trailing `\`
|
|
// continuation, so continuation lines are joined into one logical
|
|
// statement before checking, rather than inspecting each line in
|
|
// isolation.
|
|
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
|
|
for statement in join_line_continuations(script) {
|
|
if statement.contains("bread-emit ") {
|
|
assert!(
|
|
statement.contains(">/dev/null 2>&1 &"),
|
|
"statement not backgrounded/discarded: {statement}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Joins physical lines ending in a trailing `\` continuation into a
|
|
/// single logical statement, so multi-line shell invocations can be
|
|
/// checked as a whole rather than line-by-line.
|
|
fn join_line_continuations(script: &str) -> Vec<String> {
|
|
let mut statements = Vec::new();
|
|
let mut current = String::new();
|
|
for line in script.lines() {
|
|
let trimmed_end = line.trim_end();
|
|
if let Some(rest) = trimmed_end.strip_suffix('\\') {
|
|
current.push_str(rest);
|
|
current.push(' ');
|
|
} else {
|
|
current.push_str(trimmed_end);
|
|
statements.push(std::mem::take(&mut current));
|
|
}
|
|
}
|
|
if !current.is_empty() {
|
|
statements.push(current);
|
|
}
|
|
statements
|
|
}
|
|
}
|