From 26b941ed1966e34647fa7bae8b14771420d8c0c9 Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Mon, 7 Sep 2026 21:18:07 +0800 Subject: [PATCH 1/5] feat: add shell completion installation Install Bash, Zsh, and Fish completion hooks while preserving existing shell configuration. Add a Cargo installation wrapper and path completion hints. Document setup in both READMEs and cover completion behavior with shell integration tests in CI. --- .github/workflows/ci.yml | 6 + AGENTS.md | 11 ++ README.md | 46 ++++++- README_zh.md | 43 +++++- install.sh | 30 ++++ src/completion.rs | 187 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 39 ++++-- tests/workflow.rs | 290 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 640 insertions(+), 13 deletions(-) create mode 100755 install.sh create mode 100644 src/completion.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b1ad7..cd3a2de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,12 @@ jobs: run: cargo check --locked --all-targets - name: Clippy (warnings are errors) run: cargo clippy --locked --all-targets -- -D warnings + - name: Install shells for completion tests (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh fish + - name: Install Fish for completion tests (macOS) + if: runner.os == 'macOS' + run: command -v fish >/dev/null || brew install fish - name: Unit and integration tests run: cargo test --locked --all-targets - name: Documentation tests diff --git a/AGENTS.md b/AGENTS.md index 708cbb4..58405b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ one executable for macOS and Linux. Read README.md before changing its behavior. does not require a separate Rust or Git installation. - Run `cargo fmt --all`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked --all-targets`, and `cargo test --locked --doc`. +- Completion integration tests require Bash, Zsh, and Fish on PATH. - Run `taplo fmt` and `taplo fmt --check` with taplo-cli 0.10.0. - Every Rust import must be its own `use` statement. Do not use grouped braces. rustfmt's `imports_granularity = "Item"` enforces this on the pinned nightly. @@ -61,6 +62,16 @@ one executable for macOS and Linux. Read README.md before changing its behavior. All disk mutations share operation.lock; daemon.lock prevents duplicate daemons. CLI config edits are atomic and picked up by the daemon without restarting it. - service.rs renders/installs user-level launchd or systemd definitions. +- completion.rs installs explicitly requested Bash, Zsh, and Fish completion hooks. + Keep generation derived from the Clap command tree, including nested commands. + Generation and installation must work before init without creating application data. + Preserve existing shell configuration, symlinks, and permissions; replace only + FileTrail's marked block and refuse malformed markers. Respect ZDOTDIR and + XDG_CONFIG_HOME. Hooks invoke the absolute executable path with shell-specific + quoting, so upgrades at the same location update completion automatically. + install.sh wraps cargo install followed by completion installation. Never use + build.rs to modify shell configuration during builds. Test with isolated HOME, + ZDOTDIR, and XDG_CONFIG_HOME; never modify the developer's real shell profiles. - Default synchronization preserves deleted source files in the destination. Opt-in deletion applies only to previously synchronized paths. A missing source root directory must never trigger mass deletion. diff --git a/README.md b/README.md index c86fa5c..64e91ef 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,51 @@ cargo install --path . --locked filetrail --help ``` +To install FileTrail and enable Tab completion in one step: + +```sh +./install.sh +``` + +The script detects Bash, Zsh, or Fish from `$SHELL`. You can select one explicitly +with `./install.sh zsh`. It installs with Cargo, then configures that shell's +completion. Open a new shell afterward. The installation root defaults to +`${CARGO_HOME:-$HOME/.cargo}`; set `CARGO_INSTALL_ROOT` to override it. + +## Tab completion + +If you installed FileTrail with `cargo install`, enable completion with: + +```sh +filetrail completions --install +``` + +This detects your shell from `$SHELL`. To select a shell explicitly: + +```sh +filetrail completions zsh --install +filetrail completions bash --install +filetrail completions fish --install +``` + +Run the command for the shell you use, then open a new shell. Tab completes +subcommands (including `daemon` and `service` actions), options, and file paths. +For example, try `filetrail da`, `filetrail daemon st`, or +`filetrail add --f`. + +Installation preserves existing shell configuration and is safe to repeat. It +uses `.zshrc` (respecting `ZDOTDIR`), `.bashrc` and Bash's active login profile, +or Fish's completion directory (respecting `XDG_CONFIG_HOME`). Completion stays +in sync when you upgrade the executable at the same location. Run installation +again if you move it. To remove completion, delete the marked FileTrail block +from the configured files printed by the install command. + +To print a completion script for manual setup, omit `--install`: + +```sh +filetrail completions zsh +``` + ## Get started ```sh @@ -198,7 +243,6 @@ filetrail doctor filetrail logs --follow filetrail --help filetrail add --help -filetrail completions zsh ``` For development instructions, see [AGENTS.md](AGENTS.md). diff --git a/README_zh.md b/README_zh.md index 6272c16..5bd30b7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -17,6 +17,48 @@ cargo install --path . --locked filetrail --help ``` +如需一次完成 FileTrail 安装和 Tab 补全配置: + +```sh +./install.sh +``` + +脚本根据 `$SHELL` 识别 Bash、Zsh 或 Fish,也可以用 `./install.sh zsh` 显式指定。 +它先通过 Cargo 安装,再配置所选 shell 的补全,完成后重新打开 shell 即可。 +安装根目录默认为 `${CARGO_HOME:-$HOME/.cargo}`,可通过 `CARGO_INSTALL_ROOT` 覆盖。 + +## Tab 补全 + +如果使用 `cargo install` 安装 FileTrail,执行以下命令启用补全: + +```sh +filetrail completions --install +``` + +该命令根据 `$SHELL` 识别 shell,也可以显式指定: + +```sh +filetrail completions zsh --install +filetrail completions bash --install +filetrail completions fish --install +``` + +执行你所用 shell 对应的命令,然后重新打开 shell。Tab 可以补全子命令 +(包括 `daemon` 和 `service` 的操作)、选项及文件路径。例如: +`filetrail da`、`filetrail daemon st`、`filetrail add --f`。 + +安装会保留已有 shell 配置,重复执行不会添加重复配置。配置位置为 `.zshrc` +(遵循 `ZDOTDIR`)、`.bashrc` 和 Bash 当前使用的登录配置文件,或 Fish 的补全目录 +(遵循 `XDG_CONFIG_HOME`)。在相同位置升级可执行文件后,补全会同步更新; +移动可执行文件后需重新安装补全。若要移除补全,删除安装命令所列配置文件中 +带有 FileTrail 标记的配置块即可。 + +如需手动配置,可省略 `--install`,只输出补全脚本: + +```sh +filetrail completions zsh +``` + ## 开始使用 ```sh @@ -184,7 +226,6 @@ filetrail doctor filetrail logs --follow filetrail --help filetrail add --help -filetrail completions zsh ``` 开发说明见 [AGENTS.md](AGENTS.md)。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a04de2a --- /dev/null +++ b/install.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +usage() { + echo 'Usage: ./install.sh [bash|zsh|fish]' + echo 'Install FileTrail with Cargo and configure Tab completion (defaults to $SHELL).' + echo 'CARGO_INSTALL_ROOT overrides the installation root; otherwise CARGO_HOME or ~/.cargo is used.' +} + +if [ "$#" -gt 1 ]; then + usage >&2 + exit 2 +fi +case "${1-}" in + -h|--help) usage; exit 0 ;; +esac +filetrail_shell=${SHELL-} +filetrail_shell=${1:-${filetrail_shell##*/}} +case "$filetrail_shell" in + bash|zsh|fish) ;; + *) echo 'Specify bash, zsh, or fish: ./install.sh zsh' >&2; exit 2 ;; +esac + +filetrail_project=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +filetrail_install_root=${CARGO_INSTALL_ROOT:-${CARGO_HOME:-"$HOME/.cargo"}} +mkdir -p "$filetrail_install_root" +filetrail_install_root=$(CDPATH= cd -- "$filetrail_install_root" && pwd) +cd "$filetrail_project" +cargo install --path . --locked --root "$filetrail_install_root" +"$filetrail_install_root/bin/filetrail" completions "$filetrail_shell" --install diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..a1ea8a3 --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,187 @@ +use std::env; +use std::fs; +use std::io::ErrorKind; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap_complete::Shell; + +const BEGIN: &str = "# >>> filetrail completions >>>"; +const END: &str = "# <<< filetrail completions <<<"; + +/// Install startup hooks without requiring an initialized repository or data directory. +/// Hooks ask the installed binary for current definitions, so upgrades need no regeneration. +pub fn install(shell: Shell, binary: &Path) -> Result> { + let hook = hook(shell, binary)?; + let home = dirs::home_dir().context("cannot determine home directory")?; + let paths = match shell { + Shell::Bash => { + // Bash reads .bashrc for interactive shells and the first available + // login profile for login shells (including macOS Terminal). + let mut login = home.join(".bash_profile"); + for name in [".bash_profile", ".bash_login", ".profile"] { + let candidate = home.join(name); + match fs::symlink_metadata(&candidate) { + Ok(_) => { + login = candidate; + break; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + vec![home.join(".bashrc"), login] + } + Shell::Zsh => vec![environment_directory("ZDOTDIR", &home).join(".zshrc")], + Shell::Fish => vec![ + environment_directory("XDG_CONFIG_HOME", &home.join(".config")) + .join("fish/completions/filetrail.fish"), + ], + _ => bail!("automatic installation supports bash, zsh, and fish only"), + }; + + // Validate every file before writing any of them. Resolve existing symlinks + // so common dotfile setups retain both their symlinks and file permissions. + let updates = paths + .iter() + .map(|path| prepare_update(path, &hook)) + .collect::>>()?; + for (path, content, permissions) in updates { + let parent = path + .parent() + .context("missing shell configuration parent")?; + fs::create_dir_all(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.write_all(content.as_bytes())?; + if let Some(permissions) = permissions { + file.as_file().set_permissions(permissions)?; + } + file.as_file().sync_all()?; + file.persist(&path) + .with_context(|| format!("cannot update {}", path.display()))?; + } + Ok(paths) +} + +fn environment_directory(name: &str, fallback: &Path) -> PathBuf { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| fallback.to_owned()) +} + +fn hook(shell: Shell, binary: &Path) -> Result { + let binary = binary + .to_str() + .context("executable path must be valid UTF-8")?; + let quoted = match shell { + Shell::Fish => format!("'{}'", binary.replace('\\', "\\\\").replace('\'', "\\'")), + _ => format!("'{}'", binary.replace('\'', "'\\''")), + }; + let body = match shell { + Shell::Bash => format!( + "if [ -n \"${{BASH_VERSION-}}\" ] && [ -x {quoted} ]; then\n\ + case $- in\n\ + *i*) eval \"$({quoted} completions bash)\" ;;\n\ + esac\n\ + fi\n" + ), + Shell::Zsh => format!( + "if [[ -o interactive && -x {quoted} ]]; then\n\ + if (( ! $+functions[compdef] )); then\n\ + autoload -Uz compinit\n\ + compinit\n\ + fi\n\ + eval \"$({quoted} completions zsh)\"\n\ + fi\n" + ), + Shell::Fish => format!( + "if test -x {quoted}\n\ + {quoted} completions fish | source\n\ + end\n" + ), + _ => bail!("automatic installation supports bash, zsh, and fish only"), + }; + Ok(format!("{BEGIN}\n{body}{END}\n")) +} + +fn prepare_update(path: &Path, hook: &str) -> Result<(PathBuf, String, Option)> { + let (path, original, permissions) = match fs::symlink_metadata(path) { + Ok(_) => { + let resolved = fs::canonicalize(path) + .with_context(|| format!("cannot resolve {}", path.display()))?; + let metadata = fs::metadata(&resolved)?; + if !metadata.is_file() { + bail!("{} is not a regular file", path.display()); + } + let content = fs::read_to_string(&resolved) + .with_context(|| format!("cannot read {}", path.display()))?; + (resolved, content, Some(metadata.permissions())) + } + Err(error) if error.kind() == ErrorKind::NotFound => (path.to_owned(), String::new(), None), + Err(error) => return Err(error.into()), + }; + let updated = replace_hook(&original, hook) + .with_context(|| format!("invalid FileTrail completion block in {}", path.display()))?; + Ok((path, updated, permissions)) +} + +fn replace_hook(original: &str, hook: &str) -> Result { + let mut begin = None; + let mut end = None; + let mut offset = 0; + for line in original.split_inclusive('\n') { + match line.trim_end_matches(['\r', '\n']) { + BEGIN if begin.is_none() && end.is_none() => begin = Some(offset), + END if begin.is_some() && end.is_none() => end = Some(offset + line.len()), + BEGIN | END => { + bail!("duplicate or out-of-order markers; repair the marked block first") + } + _ => {} + } + offset += line.len(); + } + match (begin, end) { + (Some(begin), Some(end)) => Ok(format!("{}{hook}{}", &original[..begin], &original[end..])), + (None, None) => { + let separator = if original.is_empty() || original.ends_with('\n') { + "" + } else { + "\n" + }; + Ok(format!("{original}{separator}{hook}")) + } + _ => bail!("incomplete markers; repair the marked block first"), + } +} + +#[cfg(test)] +mod tests { + use super::BEGIN; + use super::END; + use super::replace_hook; + + #[test] + fn replaces_only_its_own_block_and_preserves_surrounding_content() { + let hook = format!("{BEGIN}\nnew\n{END}\n"); + let original = format!("before\n{BEGIN}\nold\n{END}\nafter\n"); + let updated = replace_hook(&original, &hook).unwrap(); + assert_eq!(updated, format!("before\n{hook}after\n")); + assert_eq!(replace_hook(&updated, &hook).unwrap(), updated); + assert_eq!( + replace_hook("no newline", &hook).unwrap(), + format!("no newline\n{hook}") + ); + for broken in [ + format!("{BEGIN}\n"), + format!("{END}\n"), + format!("{hook}{hook}"), + ] { + assert!(replace_hook(&broken, &hook).is_err()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index afde14b..72fdd3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ #[cfg(not(unix))] compile_error!("Filetrail currently supports macOS and Linux only"); +pub mod completion; pub mod config; pub mod daemon; pub mod git; diff --git a/src/main.rs b/src/main.rs index 10f1730..38bf481 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use filetrail::config::Store; )] struct Cli { /// Store configuration, mappings, synchronization state, sockets, and logs here [default: $HOME/.filetrail]. - #[arg(long, global = true)] + #[arg(long, global = true, value_hint = clap::ValueHint::DirPath)] data_dir: Option, #[command(subcommand)] command: Commands, @@ -33,6 +33,7 @@ struct Cli { enum Commands { /// Set the target repository and its optional platform subdirectory. Init { + #[arg(value_hint = clap::ValueHint::DirPath)] repository: PathBuf, /// Repository-relative destination root, e.g. macos or linux. #[arg(long, default_value = ".")] @@ -46,7 +47,7 @@ enum Commands { #[arg(long, conflicts_with = "from")] to: Option, /// Import source [target] lines separated by spaces; quote paths containing spaces. - #[arg(long)] + #[arg(long, value_hint = clap::ValueHint::FilePath)] from: Option, /// Propagate source deletions for files previously synchronized. #[arg(long)] @@ -105,9 +106,14 @@ enum Commands { }, /// Validate configuration, Git state, source availability, and mappings. Doctor, - /// Generate shell completion definitions. + /// Generate completions or install Tab completion for Bash, Zsh, or Fish. Completions { - shell: Shell, + /// Shell to generate/install for; --install defaults to $SHELL. + #[arg(required_unless_present = "install")] + shell: Option, + /// Configure shell startup files (safe to repeat); open a new shell afterward. + #[arg(long)] + install: bool, }, } @@ -146,13 +152,24 @@ fn main() { } fn execute(cli: Cli) -> Result<()> { - if let Commands::Completions { shell } = cli.command { - clap_complete::generate( - shell, - &mut Cli::command(), - "filetrail", - &mut std::io::stdout(), - ); + if let Commands::Completions { shell, install } = cli.command { + let shell = shell.or_else(Shell::from_env).context( + "cannot detect shell; specify bash, zsh, or fish, e.g. completions zsh --install", + )?; + if install { + let paths = filetrail::completion::install(shell, &std::env::current_exe()?)?; + for path in paths { + println!("Configured {}", path.display()); + } + println!("{shell} Tab completion installed. Open a new shell to activate it."); + } else { + clap_complete::generate( + shell, + &mut Cli::command(), + "filetrail", + &mut std::io::stdout(), + ); + } return Ok(()); } let store = Store::new(data_root(cli.data_dir)?)?; diff --git a/tests/workflow.rs b/tests/workflow.rs index 63f5d9d..a7851ca 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -13,6 +13,296 @@ use filetrail::config::Store; use git2::Repository; use tempfile::TempDir; +struct CompletionFixture { + temp: TempDir, + home: PathBuf, + binary: PathBuf, +} + +impl CompletionFixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + fs::create_dir(&home).unwrap(); + let bin = temp.path().join("bin 'quoted' $cash \\files"); + fs::create_dir(&bin).unwrap(); + let binary = bin.join("filetrail"); + fs::copy(env!("CARGO_BIN_EXE_filetrail"), &binary).unwrap(); + Self { temp, home, binary } + } + + fn command(&self, executable: impl AsRef) -> Command { + let mut command = Command::new(executable); + command + .current_dir(&self.home) + .env("HOME", &self.home) + .env("ZDOTDIR", &self.home) + .env("XDG_CONFIG_HOME", self.home.join(".config")) + .env("TERM", "xterm") + .env_remove("BASH_ENV") + .env_remove("ENV"); + command + } + + fn install(&self, shell: &str) -> String { + output_text( + self.command(&self.binary) + .env("SHELL", format!("/bin/{shell}")) + .args(["completions", "--install"]) + .output() + .unwrap(), + ) + } +} + +fn output_text(output: std::process::Output) -> String { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn completion_generation_includes_nested_commands_without_initialization() { + let f = CompletionFixture::new(); + for shell in ["bash", "zsh", "fish", "elvish", "powershell"] { + let script = output_text( + f.command(&f.binary) + .args(["completions", shell]) + .output() + .unwrap(), + ); + for expected in [ + "daemon", + "restart", + "service", + "uninstall", + "from", + "install", + ] { + assert!(script.contains(expected), "{shell}: missing {expected}"); + } + } + assert_eq!(fs::read_dir(&f.home).unwrap().count(), 0); +} + +#[test] +fn completion_installation_is_idempotent_and_preserves_existing_profiles() { + let f = CompletionFixture::new(); + let existing = "# user configuration\nexport FILETRAIL_TEST=preserved"; + for name in [ + ".bashrc", + ".bash_login", + ".zshrc", + ".config/fish/completions/filetrail.fish", + ] { + let path = f.home.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, existing).unwrap(); + } + for (shell, names) in [ + ("bash", vec![".bashrc", ".bash_login"]), + ("zsh", vec![".zshrc"]), + ("fish", vec![".config/fish/completions/filetrail.fish"]), + ] { + assert!(f.install(shell).contains("Open a new shell")); + let first: Vec<_> = names + .iter() + .map(|name| fs::read(f.home.join(name)).unwrap()) + .collect(); + f.install(shell); + for (name, first) in names.iter().zip(first) { + let path = f.home.join(name); + assert_eq!(fs::read(&path).unwrap(), first); + let content = fs::read_to_string(path).unwrap(); + assert!(content.starts_with(existing)); + assert_eq!( + content.matches("# >>> filetrail completions >>>").count(), + 1 + ); + } + } + assert!(!f.home.join(".bash_profile").exists()); + assert!(!f.home.join(".filetrail").exists()); +} + +#[test] +fn completion_installation_respects_overrides_symlinks_and_permissions() { + let f = CompletionFixture::new(); + let dotdir = f.home.join("zsh config"); + fs::create_dir(&dotdir).unwrap(); + let target = f.home.join("tracked-zshrc"); + fs::write(&target, "# tracked dotfile\n").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + symlink(&target, dotdir.join(".zshrc")).unwrap(); + output_text( + f.command(&f.binary) + .env("ZDOTDIR", &dotdir) + .args(["completions", "zsh", "--install"]) + .output() + .unwrap(), + ); + assert!( + fs::symlink_metadata(dotdir.join(".zshrc")) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert!( + fs::read_to_string(target) + .unwrap() + .starts_with("# tracked dotfile\n") + ); + let xdg = f.home.join("fish config"); + output_text( + f.command(&f.binary) + .env("XDG_CONFIG_HOME", &xdg) + .args(["completions", "fish", "--install"]) + .output() + .unwrap(), + ); + assert!(xdg.join("fish/completions/filetrail.fish").is_file()); + assert!(!f.home.join(".zshrc").exists()); + assert!(!f.home.join(".config").exists()); +} + +#[test] +fn completion_installation_refuses_invalid_input_before_changing_profiles() { + let f = CompletionFixture::new(); + fs::write(f.home.join(".bashrc"), "# keep me\n").unwrap(); + fs::write( + f.home.join(".bash_profile"), + "# >>> filetrail completions >>>\n", + ) + .unwrap(); + for args in [ + vec!["completions", "bash", "--install"], + vec!["completions", "elvish", "--install"], + vec!["completions", "--install"], + ] { + let output = f + .command(&f.binary) + .env_remove("SHELL") + .args(args) + .output() + .unwrap(); + assert!(!output.status.success()); + } + assert_eq!( + fs::read_to_string(f.home.join(".bashrc")).unwrap(), + "# keep me\n" + ); + assert_eq!(fs::read_dir(&f.home).unwrap().count(), 2); +} + +#[test] +fn bash_completion_loads_and_completes_commands_options_and_paths() { + let f = CompletionFixture::new(); + f.install("bash"); + fs::write(f.home.join("example.txt"), "").unwrap(); + for (words, index, expected) in [ + ("filetrail co", "1", "commit"), + ("filetrail daemon st", "2", "start"), + ("filetrail service un", "2", "uninstall"), + ("filetrail add --f", "2", "--from"), + ("filetrail add --from ex", "3", "example.txt"), + ] { + let script = format!( + "complete -p filetrail >/dev/null || exit 1; COMP_WORDS=({words}); COMP_CWORD={index}; _filetrail filetrail \"${{COMP_WORDS[COMP_CWORD]}}\" \"${{COMP_WORDS[COMP_CWORD-1]}}\"; printf '%s\\n' \"${{COMPREPLY[@]}}\"" + ); + let output = output_text( + f.command("bash") + .args(["--noprofile", "-ic", &script]) + .output() + .unwrap(), + ); + assert!( + output.lines().any(|line| line == expected), + "{words}: {output}" + ); + } +} + +#[test] +fn zsh_completion_registers_with_and_without_existing_compinit() { + let f = CompletionFixture::new(); + for prefix in ["", "autoload -Uz compinit\ncompinit\n"] { + fs::write(f.home.join(".zshrc"), prefix).unwrap(); + f.install("zsh"); + output_text( + f.command("zsh") + .args([ + "-d", + "-ic", + "[[ ${_comps[filetrail]-} == _filetrail ]] && (( $+functions[_filetrail] ))", + ]) + .output() + .unwrap(), + ); + } +} + +#[test] +fn fish_completion_autoloads_commands_options_and_paths() { + let f = CompletionFixture::new(); + f.install("fish"); + fs::write(f.home.join("example.txt"), "").unwrap(); + for (line, expected) in [ + ("filetrail co", "commit"), + ("filetrail daemon st", "start"), + ("filetrail service un", "uninstall"), + ("filetrail add --f", "--from"), + ("filetrail add --from ex", "example.txt"), + ] { + let script = format!("complete -C '{line}'"); + let output = output_text(f.command("fish").args(["-c", &script]).output().unwrap()); + assert!( + output + .lines() + .any(|line| line.split('\t').next() == Some(expected)), + "{line}: {output}" + ); + } +} + +#[test] +fn install_wrapper_uses_cargo_then_installs_completion_only_on_success() { + let f = CompletionFixture::new(); + let fake_bin = f.temp.path().join("fake-bin"); + fs::create_dir(&fake_bin).unwrap(); + let cargo = fake_bin.join("cargo"); + fs::write(&cargo, "#!/bin/sh\nexit 19\n").unwrap(); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap(); + let root = f.temp.path().join("install root"); + let run = || { + f.command("sh") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/install.sh")) + .arg("zsh") + .env("CARGO_INSTALL_ROOT", &root) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("FILETRAIL_TEST_BINARY", &f.binary) + .output() + .unwrap() + }; + assert_eq!(run().status.code(), Some(19)); + assert!(!f.home.join(".zshrc").exists()); + fs::write(&cargo, "#!/bin/sh\nset -eu\n[ \"$1 $2 $3 $4 $5\" = 'install --path . --locked --root' ]\nmkdir -p \"$6/bin\"\ncp \"$FILETRAIL_TEST_BINARY\" \"$6/bin/filetrail\"\n").unwrap(); + output_text(run()); + assert!(root.join("bin/filetrail").is_file()); + assert!( + fs::read_to_string(f.home.join(".zshrc")) + .unwrap() + .contains(&format!("{}/bin/filetrail", root.display())) + ); +} + struct Fixture { _temp: TempDir, store: Store, From 90278958ece4ebbc69f257ee92b1dd0bb2ef7d1e Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Mon, 7 Sep 2026 21:26:26 +0800 Subject: [PATCH 2/5] test: isolate shell completion executable lookup Prepend the fixture binary directory to PATH so Fish autoloads completion in clean CI environments. Verify executable resolution and include stderr in completion failures. --- tests/workflow.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/workflow.rs b/tests/workflow.rs index a7851ca..3da8b8b 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -32,9 +32,16 @@ impl CompletionFixture { } fn command(&self, executable: impl AsRef) -> Command { + // Fish only autoloads completions for commands it can resolve. Model an + // installed binary instead of depending on FileTrail in the user's PATH. + let mut paths = vec![self.binary.parent().unwrap().to_owned()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); let mut command = Command::new(executable); command .current_dir(&self.home) + .env("PATH", std::env::join_paths(paths).unwrap()) .env("HOME", &self.home) .env("ZDOTDIR", &self.home) .env("XDG_CONFIG_HOME", self.home.join(".config")) @@ -253,6 +260,13 @@ fn zsh_completion_registers_with_and_without_existing_compinit() { fn fish_completion_autoloads_commands_options_and_paths() { let f = CompletionFixture::new(); f.install("fish"); + let resolved = output_text( + f.command("fish") + .args(["-c", "command -s filetrail"]) + .output() + .unwrap(), + ); + assert_eq!(resolved.trim_end(), f.binary.to_str().unwrap()); fs::write(f.home.join("example.txt"), "").unwrap(); for (line, expected) in [ ("filetrail co", "commit"), @@ -262,12 +276,14 @@ fn fish_completion_autoloads_commands_options_and_paths() { ("filetrail add --from ex", "example.txt"), ] { let script = format!("complete -C '{line}'"); - let output = output_text(f.command("fish").args(["-c", &script]).output().unwrap()); + let result = f.command("fish").args(["-c", &script]).output().unwrap(); + let stderr = String::from_utf8_lossy(&result.stderr).into_owned(); + let output = output_text(result); assert!( output .lines() .any(|line| line.split('\t').next() == Some(expected)), - "{line}: {output}" + "{line}: {output}\nstderr: {stderr}" ); } } From 884222922de532179670535424127782a464b70c Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Mon, 7 Sep 2026 23:24:49 +0800 Subject: [PATCH 3/5] fix: initialize zsh completion without interactive prompts Use compinit -i to skip insecure completion directories while retaining permission checks. Guard registration on compdef availability and test safe and insecure directories without a terminal. Run GitHub Actions only for pull requests. --- .github/workflows/ci.yml | 2 -- AGENTS.md | 3 +++ src/completion.rs | 4 +++- tests/workflow.rs | 44 ++++++++++++++++++++++++++++++++-------- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd3a2de..089553e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,7 @@ name: CI on: - push: pull_request: - workflow_dispatch: permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 58405b3..972e395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,9 @@ one executable for macOS and Linux. Read README.md before changing its behavior. FileTrail's marked block and refuse malformed markers. Respect ZDOTDIR and XDG_CONFIG_HOME. Hooks invoke the absolute executable path with shell-specific quoting, so upgrades at the same location update completion automatically. + Initialize Zsh with compinit -i: retain permission checks and skip insecure + completion directories without prompting. Do not bypass the audit with -u or + -C. Test safe and insecure fpath entries without a TTY. install.sh wraps cargo install followed by completion installation. Never use build.rs to modify shell configuration during builds. Test with isolated HOME, ZDOTDIR, and XDG_CONFIG_HOME; never modify the developer's real shell profiles. diff --git a/src/completion.rs b/src/completion.rs index a1ea8a3..0fe87d9 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -94,9 +94,11 @@ fn hook(shell: Shell, binary: &Path) -> Result { "if [[ -o interactive && -x {quoted} ]]; then\n\ if (( ! $+functions[compdef] )); then\n\ autoload -Uz compinit\n\ - compinit\n\ + compinit -i\n\ fi\n\ + if (( $+functions[compdef] )); then\n\ eval \"$({quoted} completions zsh)\"\n\ + fi\n\ fi\n" ), Shell::Fish => format!( diff --git a/tests/workflow.rs b/tests/workflow.rs index 3da8b8b..fb8dc89 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -239,20 +239,46 @@ fn bash_completion_loads_and_completes_commands_options_and_paths() { #[test] fn zsh_completion_registers_with_and_without_existing_compinit() { - let f = CompletionFixture::new(); - for prefix in ["", "autoload -Uz compinit\ncompinit\n"] { - fs::write(f.home.join(".zshrc"), prefix).unwrap(); - f.install("zsh"); - output_text( - f.command("zsh") + for insecure in [false, true] { + for prefix in ["", "autoload -Uz compinit\ncompinit -i\n"] { + let f = CompletionFixture::new(); + let functions = f.temp.path().join("completion functions"); + fs::create_dir(&functions).unwrap(); + fs::write( + functions.join("_filetrail_fixture"), + "#compdef filetrail-fixture\n", + ) + .unwrap(); + fs::set_permissions( + &functions, + fs::Permissions::from_mode(if insecure { 0o777 } else { 0o755 }), + ) + .unwrap(); + fs::write( + f.home.join(".zshrc"), + format!("fpath=(\"$FILETRAIL_TEST_FPATH\" $fpath)\n{prefix}"), + ) + .unwrap(); + f.install("zsh"); + // No TTY: an audit prompt must not abort completion initialization. + // Safe fixture completions should load; unsafe ones must be ignored. + let output = f.command("zsh") + .env("FILETRAIL_TEST_FPATH", &functions) + .env("FILETRAIL_TEST_COMPLETION", if insecure { "" } else { "_filetrail_fixture" }) .args([ "-d", "-ic", - "[[ ${_comps[filetrail]-} == _filetrail ]] && (( $+functions[_filetrail] ))", + "[[ ${_comps[filetrail]-} == _filetrail && ${_comps[filetrail-fixture]-} == $FILETRAIL_TEST_COMPLETION ]] && (( $+functions[_filetrail] ))", ]) .output() - .unwrap(), - ); + .unwrap(); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output_text(output); + } } } From 75a6d7290891f1ac9a9edb02256c95cf2ecd4a94 Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Mon, 7 Sep 2026 23:37:29 +0800 Subject: [PATCH 4/5] feat: use FileTrail commit prefix and portable completion paths Use FileTrail: for generated commit messages and represent Home paths with an expandable, quoted HOME variable in completion hooks and installation output. Test path quoting and Home relocation, update both READMEs, and simplify the architecture guidance in AGENTS.md. --- AGENTS.md | 71 +++++-------------------- README.md | 6 ++- README_zh.md | 5 +- src/completion.rs | 131 ++++++++++++++++++++++++++++++++++++++++------ src/git.rs | 4 +- src/main.rs | 4 +- tests/workflow.rs | 53 ++++++++++++++++--- 7 files changed, 186 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 972e395..05f57e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,63 +26,20 @@ one executable for macOS and Linux. Read README.md before changing its behavior. ## Architecture and invariants -- config.rs owns editable TOML mappings, validation, atomic config writes, and the shared - operation lock. Repository path, init subdirectory, and entry target are - separate concepts: destination = repository / subdir / target / relative file. - Without `add --to`, sources inside Home use their Home-relative path; sources - outside Home use their absolute path with the leading `/` removed. The same - default applies to list imports. Explicit targets override either default. -- Application data defaults to `$HOME/.filetrail` on both macOS and Linux. The - `--data-dir` option overrides this location for all configuration, mappings, - synchronization state, locks, sockets, and logs. Use the same resolved data - directory when spawning the daemon or rendering system service definitions. -- sync.rs reconciles current filesystem contents, records ownership and content - baselines, protects external destination edits, and copies without following - symlinks. Events are hints; periodic scans recover missed events. -- state.rs persists synchronization state in `state.db` using bundled SQLite. - Ownership, baselines, conflicts, and the last sync timestamp live in separate - tables. Apply related changes in one transaction, updating only changed rows. - Ownership survives a source deletion so Git can still commit that deletion. - Conflicts may refer to files not yet owned by FileTrail. - Validate application_id and user_version before accessing a database; never - silently reset damaged or unknown schemas. Publish a new database only after - its initial transaction succeeds. Reads and dry runs must not create a database. - There is no legacy JSON state reader or migration path. Callers hold the shared - operation lock across a state read/modify/write sequence; SQLite also provides - transactional consistency for state readers and writers. -- manifest.rs parses file-list lines as `source [target]` separated by whitespace. - Single/double quotes and escapes support spaces in either path; comments and - blank lines are allowed. Never execute a shell or expand variables in the list. - Reject extra fields, empty paths, and malformed quotes with a line-numbered - error, and validate the complete list before saving any mappings. -- git.rs handles local status/diff/commit. Background sync never stages or commits. - A commit includes only previously synchronized, still-managed paths. Preexisting - staged changes cause a refusal, without changing the index. -- daemon.rs owns native watching, periodic reconciliation, and the local socket. - All disk mutations share operation.lock; daemon.lock prevents duplicate daemons. - CLI config edits are atomic and picked up by the daemon without restarting it. -- service.rs renders/installs user-level launchd or systemd definitions. -- completion.rs installs explicitly requested Bash, Zsh, and Fish completion hooks. - Keep generation derived from the Clap command tree, including nested commands. - Generation and installation must work before init without creating application data. - Preserve existing shell configuration, symlinks, and permissions; replace only - FileTrail's marked block and refuse malformed markers. Respect ZDOTDIR and - XDG_CONFIG_HOME. Hooks invoke the absolute executable path with shell-specific - quoting, so upgrades at the same location update completion automatically. - Initialize Zsh with compinit -i: retain permission checks and skip insecure - completion directories without prompting. Do not bypass the audit with -u or - -C. Test safe and insecure fpath entries without a TTY. - install.sh wraps cargo install followed by completion installation. Never use - build.rs to modify shell configuration during builds. Test with isolated HOME, - ZDOTDIR, and XDG_CONFIG_HOME; never modify the developer's real shell profiles. -- Default synchronization preserves deleted source files in the destination. - Opt-in deletion applies only to previously synchronized paths. A missing source - root directory must never trigger mass deletion. -- Never permit repository-relative paths to escape via `..`, `.git`, or a - destination ancestor symlink. Do not overwrite external destination edits - unless the user explicitly requests conflict resolution for that path. -- Default commit messages start with `filetrail: ` and list every selected file - change. Explicit user messages are preserved. No automatic commit or push. +Core modules: `config.rs` manages mappings, `state.rs` persists sync state, +`sync.rs` and `daemon.rs` handle synchronization, and `git.rs` handles Git operations. +Consult the source and tests for implementation details. + +- CLI, daemon, and services share the same data directory and serialize mutations. +- Keep sync destinations inside the configured repository. Never follow symlinks + outside it or modify `.git` through synchronization. +- Protect external destination edits. Deletion is opt-in and limited to managed + files; an unavailable source directory must never trigger mass deletion. +- Background sync never stages or commits. Explicit commits include only managed + changes and must preserve the user's existing staging. +- Validate before writing, update state atomically, and keep dry runs read-only. + Never silently discard corrupt state. +- Shell setup must preserve existing user configuration and safely quote paths. ## Using Filetrail as an agent diff --git a/README.md b/README.md index 64e91ef..22a3d9a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ For example, try `filetrail da`, `filetrail daemon st`, or Installation preserves existing shell configuration and is safe to repeat. It uses `.zshrc` (respecting `ZDOTDIR`), `.bashrc` and Bash's active login profile, -or Fish's completion directory (respecting `XDG_CONFIG_HOME`). Completion stays +or Fish's completion directory (respecting `XDG_CONFIG_HOME`). Home paths use +`$HOME` in the installed hooks and command output, so your username is not embedded. +Paths outside Home retain their absolute location. Completion stays in sync when you upgrade the executable at the same location. Run installation again if you move it. To remove completion, delete the marked FileTrail block from the configured files printed by the install command. @@ -168,7 +170,7 @@ already staged. Set your Git name and email before your first commit. Without `-m`, FileTrail generates a message listing the selected changes: ```text -filetrail: sync 3 files (+1 ~1 -1) +FileTrail: sync 3 files (+1 ~1 -1) add "macos/.config/nvim/init.lua" delete "macos/.oldrc" diff --git a/README_zh.md b/README_zh.md index 5bd30b7..c6e323d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -49,7 +49,8 @@ filetrail completions fish --install 安装会保留已有 shell 配置,重复执行不会添加重复配置。配置位置为 `.zshrc` (遵循 `ZDOTDIR`)、`.bashrc` 和 Bash 当前使用的登录配置文件,或 Fish 的补全目录 -(遵循 `XDG_CONFIG_HOME`)。在相同位置升级可执行文件后,补全会同步更新; +(遵循 `XDG_CONFIG_HOME`)。安装的补全配置和命令输出使用 `$HOME` 表示 Home 路径, +不写入用户名;Home 以外的路径保留绝对位置。在相同位置升级可执行文件后,补全会同步更新; 移动可执行文件后需重新安装补全。若要移除补全,删除安装命令所列配置文件中 带有 FileTrail 标记的配置块即可。 @@ -155,7 +156,7 @@ filetrail commit -- macos/.zshrc 不传 `-m` 时,FileTrail 会自动生成列出本次变化的消息: ```text -filetrail: sync 3 files (+1 ~1 -1) +FileTrail: sync 3 files (+1 ~1 -1) add "macos/.config/nvim/init.lua" delete "macos/.oldrc" diff --git a/src/completion.rs b/src/completion.rs index 0fe87d9..cab777f 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -10,14 +10,14 @@ use anyhow::Result; use anyhow::bail; use clap_complete::Shell; -const BEGIN: &str = "# >>> filetrail completions >>>"; -const END: &str = "# <<< filetrail completions <<<"; +const BEGIN: &str = "# >>> FileTrail completions >>>"; +const END: &str = "# <<< FileTrail completions <<<"; /// Install startup hooks without requiring an initialized repository or data directory. /// Hooks ask the installed binary for current definitions, so upgrades need no regeneration. pub fn install(shell: Shell, binary: &Path) -> Result> { - let hook = hook(shell, binary)?; let home = dirs::home_dir().context("cannot determine home directory")?; + let hook = hook(shell, binary, &home)?; let paths = match shell { Shell::Bash => { // Bash reads .bashrc for interactive shells and the first available @@ -62,7 +62,7 @@ pub fn install(shell: Shell, binary: &Path) -> Result> { } file.as_file().sync_all()?; file.persist(&path) - .with_context(|| format!("cannot update {}", path.display()))?; + .with_context(|| format!("cannot update {}", display_path(&path)))?; } Ok(paths) } @@ -74,14 +74,60 @@ fn environment_directory(name: &str, fallback: &Path) -> PathBuf { .unwrap_or_else(|| fallback.to_owned()) } -fn hook(shell: Shell, binary: &Path) -> Result { +fn home_relative(path: &Path, home: &Path) -> Option { + path.strip_prefix(home) + .ok() + .map(Path::to_owned) + .or_else(|| { + // current_exe may resolve symlinks that are still present in HOME + // (for example /var versus /private/var on macOS). + let home = fs::canonicalize(home).ok()?; + fs::canonicalize(path) + .ok()? + .strip_prefix(home) + .ok() + .map(Path::to_owned) + }) +} + +/// Abbreviate Home paths in completion installation output. +pub fn display_path(path: &Path) -> String { + match dirs::home_dir().and_then(|home| home_relative(path, &home)) { + Some(relative) if relative.as_os_str().is_empty() => "$HOME".to_owned(), + Some(relative) => format!("$HOME/{}", relative.display()), + None => path.display().to_string(), + } +} + +fn executable_expression(shell: Shell, binary: &Path, home: &Path) -> Result { + if let Some(relative) = home_relative(binary, home) { + let relative = relative + .to_str() + .context("executable path must be valid UTF-8")?; + let mut quoted = String::from("\"$HOME"); + if !relative.is_empty() { + quoted.push('/'); + } + for character in relative.chars() { + if matches!(character, '\\' | '"' | '$') || (character == '`' && shell != Shell::Fish) { + quoted.push('\\'); + } + quoted.push(character); + } + quoted.push('"'); + return Ok(quoted); + } let binary = binary .to_str() .context("executable path must be valid UTF-8")?; - let quoted = match shell { + Ok(match shell { Shell::Fish => format!("'{}'", binary.replace('\\', "\\\\").replace('\'', "\\'")), _ => format!("'{}'", binary.replace('\'', "'\\''")), - }; + }) +} + +fn hook(shell: Shell, binary: &Path, home: &Path) -> Result { + let quoted = executable_expression(shell, binary, home)?; let body = match shell { Shell::Bash => format!( "if [ -n \"${{BASH_VERSION-}}\" ] && [ -x {quoted} ]; then\n\ @@ -115,20 +161,24 @@ fn prepare_update(path: &Path, hook: &str) -> Result<(PathBuf, String, Option { let resolved = fs::canonicalize(path) - .with_context(|| format!("cannot resolve {}", path.display()))?; + .with_context(|| format!("cannot resolve {}", display_path(path)))?; let metadata = fs::metadata(&resolved)?; if !metadata.is_file() { - bail!("{} is not a regular file", path.display()); + bail!("{} is not a regular file", display_path(path)); } let content = fs::read_to_string(&resolved) - .with_context(|| format!("cannot read {}", path.display()))?; + .with_context(|| format!("cannot read {}", display_path(path)))?; (resolved, content, Some(metadata.permissions())) } Err(error) if error.kind() == ErrorKind::NotFound => (path.to_owned(), String::new(), None), Err(error) => return Err(error.into()), }; - let updated = replace_hook(&original, hook) - .with_context(|| format!("invalid FileTrail completion block in {}", path.display()))?; + let updated = replace_hook(&original, hook).with_context(|| { + format!( + "invalid FileTrail completion block in {}", + display_path(&path) + ) + })?; Ok((path, updated, permissions)) } @@ -137,13 +187,17 @@ fn replace_hook(original: &str, hook: &str) -> Result { let mut end = None; let mut offset = 0; for line in original.split_inclusive('\n') { - match line.trim_end_matches(['\r', '\n']) { - BEGIN if begin.is_none() && end.is_none() => begin = Some(offset), - END if begin.is_some() && end.is_none() => end = Some(offset + line.len()), - BEGIN | END => { + let marker = line.trim_end_matches(['\r', '\n']); + if marker.eq_ignore_ascii_case(BEGIN) { + if begin.is_some() || end.is_some() { + bail!("duplicate or out-of-order markers; repair the marked block first") + } + begin = Some(offset); + } else if marker.eq_ignore_ascii_case(END) { + if begin.is_none() || end.is_some() { bail!("duplicate or out-of-order markers; repair the marked block first") } - _ => {} + end = Some(offset + line.len()); } offset += line.len(); } @@ -163,16 +217,59 @@ fn replace_hook(original: &str, hook: &str) -> Result { #[cfg(test)] mod tests { + use std::fs; + use std::os::unix::fs::symlink; + use std::path::Path; + + use clap_complete::Shell; + use super::BEGIN; use super::END; + use super::executable_expression; use super::replace_hook; + #[test] + fn executable_paths_use_home_only_at_component_boundaries() { + let home = Path::new("/home/alice"); + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { + assert_eq!( + executable_expression(shell, &home.join(".cargo/bin/filetrail"), home).unwrap(), + "\"$HOME/.cargo/bin/filetrail\"" + ); + for external in ["/opt/bin/filetrail", "/home/alice-other/bin/filetrail"] { + assert_eq!( + executable_expression(shell, Path::new(external), home).unwrap(), + format!("'{external}'") + ); + } + } + } + + #[test] + fn resolved_executable_paths_match_a_symlinked_home() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("real-home"); + fs::create_dir(&home).unwrap(); + let binary = home.join("filetrail"); + fs::write(&binary, "").unwrap(); + let alias = temp.path().join("home-alias"); + symlink(&home, &alias).unwrap(); + assert_eq!( + executable_expression(Shell::Zsh, &fs::canonicalize(binary).unwrap(), &alias).unwrap(), + "\"$HOME/filetrail\"" + ); + } + #[test] fn replaces_only_its_own_block_and_preserves_surrounding_content() { let hook = format!("{BEGIN}\nnew\n{END}\n"); let original = format!("before\n{BEGIN}\nold\n{END}\nafter\n"); let updated = replace_hook(&original, &hook).unwrap(); assert_eq!(updated, format!("before\n{hook}after\n")); + assert_eq!( + replace_hook(&original.to_lowercase(), &hook).unwrap(), + updated + ); assert_eq!(replace_hook(&updated, &hook).unwrap(), updated); assert_eq!( replace_hook("no newline", &hook).unwrap(), diff --git a/src/git.rs b/src/git.rs index 42a0898..0095bb0 100644 --- a/src/git.rs +++ b/src/git.rs @@ -213,7 +213,7 @@ pub fn default_message(changes: &[Change]) -> String { } details.sort(); format!( - "filetrail: sync {} files (+{added} ~{modified} -{deleted})\n\n{}", + "FileTrail: sync {} files (+{added} ~{modified} -{deleted})\n\n{}", changes.len(), details.join("\n") ) @@ -328,7 +328,7 @@ mod tests { ]); assert_eq!( message, - "filetrail: sync 3 files (+1 ~1 -1)\n\nadd \"a\"\ndelete \"z\"\nmodify \"m\\nname\"" + "FileTrail: sync 3 files (+1 ~1 -1)\n\nadd \"a\"\ndelete \"z\"\nmodify \"m\\nname\"" ); } } diff --git a/src/main.rs b/src/main.rs index 38bf481..15353ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,7 +87,7 @@ enum Commands { Diff { paths: Vec, }, - /// Commit managed changes; generates a filetrail: message by default. + /// Commit managed changes; generates a FileTrail: message by default. Commit { #[arg(short, long)] message: Option, @@ -159,7 +159,7 @@ fn execute(cli: Cli) -> Result<()> { if install { let paths = filetrail::completion::install(shell, &std::env::current_exe()?)?; for path in paths { - println!("Configured {}", path.display()); + println!("Configured {}", filetrail::completion::display_path(&path)); } println!("{shell} Tab completion installed. Open a new shell to activate it."); } else { diff --git a/tests/workflow.rs b/tests/workflow.rs index fb8dc89..b9da92b 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -24,7 +24,7 @@ impl CompletionFixture { let temp = tempfile::tempdir().unwrap(); let home = temp.path().join("home"); fs::create_dir(&home).unwrap(); - let bin = temp.path().join("bin 'quoted' $cash \\files"); + let bin = home.join("bin 'quoted' $cash \\files \"double\" `literal`"); fs::create_dir(&bin).unwrap(); let binary = bin.join("filetrail"); fs::copy(env!("CARGO_BIN_EXE_filetrail"), &binary).unwrap(); @@ -93,7 +93,7 @@ fn completion_generation_includes_nested_commands_without_initialization() { assert!(script.contains(expected), "{shell}: missing {expected}"); } } - assert_eq!(fs::read_dir(&f.home).unwrap().count(), 0); + assert_eq!(fs::read_dir(&f.home).unwrap().count(), 1); } #[test] @@ -115,7 +115,10 @@ fn completion_installation_is_idempotent_and_preserves_existing_profiles() { ("zsh", vec![".zshrc"]), ("fish", vec![".config/fish/completions/filetrail.fish"]), ] { - assert!(f.install(shell).contains("Open a new shell")); + let output = f.install(shell); + assert!(output.contains("Open a new shell")); + assert!(output.contains("Configured $HOME/")); + assert!(!output.contains(f.home.to_str().unwrap())); let first: Vec<_> = names .iter() .map(|name| fs::read(f.home.join(name)).unwrap()) @@ -126,8 +129,10 @@ fn completion_installation_is_idempotent_and_preserves_existing_profiles() { assert_eq!(fs::read(&path).unwrap(), first); let content = fs::read_to_string(path).unwrap(); assert!(content.starts_with(existing)); + assert!(content.contains("\"$HOME/")); + assert!(!content.contains(f.home.to_str().unwrap())); assert_eq!( - content.matches("# >>> filetrail completions >>>").count(), + content.matches("# >>> FileTrail completions >>>").count(), 1 ); } @@ -206,7 +211,7 @@ fn completion_installation_refuses_invalid_input_before_changing_profiles() { fs::read_to_string(f.home.join(".bashrc")).unwrap(), "# keep me\n" ); - assert_eq!(fs::read_dir(&f.home).unwrap().count(), 2); + assert_eq!(fs::read_dir(&f.home).unwrap().count(), 3); } #[test] @@ -314,6 +319,42 @@ fn fish_completion_autoloads_commands_options_and_paths() { } } +#[test] +fn completion_hooks_follow_home_after_relocation() { + let mut f = CompletionFixture::new(); + for shell in ["bash", "zsh", "fish"] { + f.install(shell); + } + let binary_relative = f.binary.strip_prefix(&f.home).unwrap().to_owned(); + let relocated = f.temp.path().join("new home 'quoted' $cash"); + fs::rename(&f.home, &relocated).unwrap(); + f.home = relocated; + f.binary = f.home.join(binary_relative); + for (shell, arguments) in [ + ("bash", vec!["--noprofile", "-ic", "complete -p filetrail"]), + ( + "zsh", + vec![ + "-d", + "-ic", + "[[ ${_comps[filetrail]-} == _filetrail ]] && (( $+functions[_filetrail] ))", + ], + ), + ("fish", vec!["-c", "complete -C 'filetrail co'"]), + ] { + let result = f.command(shell).args(arguments).output().unwrap(); + let output = output_text(result); + if shell == "fish" { + assert!( + output + .lines() + .any(|line| line.split('\t').next() == Some("commit")), + "{output}" + ); + } + } +} + #[test] fn install_wrapper_uses_cargo_then_installs_completion_only_on_success() { let f = CompletionFixture::new(); @@ -586,7 +627,7 @@ fn default_commit_message_and_untracked_diff_include_files() { filetrail::git::commit(&f.store, None, &[]).unwrap(); let repo = Repository::open(&f.repository).unwrap(); let commit = repo.head().unwrap().peel_to_commit().unwrap(); - assert!(commit.message().unwrap().starts_with("filetrail: ")); + assert!(commit.message().unwrap().starts_with("FileTrail: ")); assert!(commit.message().unwrap().contains("macos/config/a")); assert!( commit From 7196108d3628c4da709145649c01ead21175c710 Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Tue, 8 Sep 2026 00:18:23 +0800 Subject: [PATCH 5/5] test: prevent executable busy races in completion fixtures Wait for inherited writable descriptors to close before executing copied binaries. Use a fixed Cargo test script and cover concurrent fixture creation and execution. --- tests/fixtures/cargo.sh | 11 ++++++++ tests/workflow.rs | 60 +++++++++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 8 deletions(-) create mode 100755 tests/fixtures/cargo.sh diff --git a/tests/fixtures/cargo.sh b/tests/fixtures/cargo.sh new file mode 100755 index 0000000..8b8d998 --- /dev/null +++ b/tests/fixtures/cargo.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Cargo stand-in for install.sh integration tests; never modifies real user data. +set -eu + +if [ "$FILETRAIL_TEST_CARGO_EXIT" != 0 ]; then + exit "$FILETRAIL_TEST_CARGO_EXIT" +fi + +[ "$1 $2 $3 $4 $5" = 'install --path . --locked --root' ] +mkdir -p "$6/bin" +cp "$FILETRAIL_TEST_BINARY" "$6/bin/filetrail" diff --git a/tests/workflow.rs b/tests/workflow.rs index b9da92b..0c86f91 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -10,6 +10,7 @@ use std::time::Instant; use filetrail::config::Config; use filetrail::config::Entry; use filetrail::config::Store; +use fs2::FileExt; use git2::Repository; use tempfile::TempDir; @@ -27,7 +28,7 @@ impl CompletionFixture { let bin = home.join("bin 'quoted' $cash \\files \"double\" `literal`"); fs::create_dir(&bin).unwrap(); let binary = bin.join("filetrail"); - fs::copy(env!("CARGO_BIN_EXE_filetrail"), &binary).unwrap(); + copy_executable(Path::new(env!("CARGO_BIN_EXE_filetrail")), &binary); Self { temp, home, binary } } @@ -62,16 +63,55 @@ impl CompletionFixture { } } +fn copy_executable(source: &Path, target: &Path) { + let mut input = fs::File::open(source).unwrap(); + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(target) + .unwrap(); + std::io::copy(&mut input, &mut output).unwrap(); + output + .set_permissions(input.metadata().unwrap().permissions()) + .unwrap(); + + // A concurrent fork can inherit this writer until its exec closes it, even + // after we drop our descriptor (rust-lang/rust#114554). The exclusive lock + // belongs to that shared open-file description. Closing and reopening with + // a shared lock waits for every inherited writer, preventing ETXTBSY. + FileExt::lock_exclusive(&output).unwrap(); + drop(output); + let reader = fs::File::open(target).unwrap(); + FileExt::lock_shared(&reader).unwrap(); +} + fn output_text(output: std::process::Output) -> String { assert!( output.status.success(), - "stdout: {}\nstderr: {}", + "status: {}\nstdout: {}\nstderr: {}", + output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } +#[test] +fn completion_fixtures_can_be_created_and_executed_concurrently() { + let start = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| { + start.wait(); + for _ in 0..8 { + let f = CompletionFixture::new(); + assert!(f.install("bash").contains("Tab completion installed")); + } + }); + } + }); +} + #[test] fn completion_generation_includes_nested_commands_without_initialization() { let f = CompletionFixture::new(); @@ -361,23 +401,27 @@ fn install_wrapper_uses_cargo_then_installs_completion_only_on_success() { let fake_bin = f.temp.path().join("fake-bin"); fs::create_dir(&fake_bin).unwrap(); let cargo = fake_bin.join("cargo"); - fs::write(&cargo, "#!/bin/sh\nexit 19\n").unwrap(); - fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap(); + // Use a checked-in script so concurrent forks cannot inherit a writer for it. + symlink( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cargo.sh"), + &cargo, + ) + .unwrap(); let root = f.temp.path().join("install root"); - let run = || { + let run = |exit_code: &str| { f.command("sh") .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/install.sh")) .arg("zsh") .env("CARGO_INSTALL_ROOT", &root) .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) .env("FILETRAIL_TEST_BINARY", &f.binary) + .env("FILETRAIL_TEST_CARGO_EXIT", exit_code) .output() .unwrap() }; - assert_eq!(run().status.code(), Some(19)); + assert_eq!(run("19").status.code(), Some(19)); assert!(!f.home.join(".zshrc").exists()); - fs::write(&cargo, "#!/bin/sh\nset -eu\n[ \"$1 $2 $3 $4 $5\" = 'install --path . --locked --root' ]\nmkdir -p \"$6/bin\"\ncp \"$FILETRAIL_TEST_BINARY\" \"$6/bin/filetrail\"\n").unwrap(); - output_text(run()); + output_text(run("0")); assert!(root.join("bin/filetrail").is_file()); assert!( fs::read_to_string(f.home.join(".zshrc"))