Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers
This commit is contained in:
parent
89c5849539
commit
1208c5d1b7
29 changed files with 4098 additions and 339 deletions
512
bread-cli/src/hooks_shell.rs
Normal file
512
bread-cli/src/hooks_shell.rs
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
//! `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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue