diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b1ad7..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 @@ -50,6 +48,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..05f57e8 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. @@ -25,50 +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. -- 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 c86fa5c..22a3d9a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,53 @@ 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`). 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. + +To print a completion script for manual setup, omit `--install`: + +```sh +filetrail completions zsh +``` + ## Get started ```sh @@ -123,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" @@ -198,7 +245,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..c6e323d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -17,6 +17,49 @@ 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`)。安装的补全配置和命令输出使用 `$HOME` 表示 Home 路径, +不写入用户名;Home 以外的路径保留绝对位置。在相同位置升级可执行文件后,补全会同步更新; +移动可执行文件后需重新安装补全。若要移除补全,删除安装命令所列配置文件中 +带有 FileTrail 标记的配置块即可。 + +如需手动配置,可省略 `--install`,只输出补全脚本: + +```sh +filetrail completions zsh +``` + ## 开始使用 ```sh @@ -113,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" @@ -184,7 +227,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..cab777f --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,286 @@ +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 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 + // 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 {}", display_path(&path)))?; + } + 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 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")?; + 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\ + 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 -i\n\ + fi\n\ + if (( $+functions[compdef] )); then\n\ + eval \"$({quoted} completions zsh)\"\n\ + fi\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 {}", display_path(path)))?; + let metadata = fs::metadata(&resolved)?; + if !metadata.is_file() { + bail!("{} is not a regular file", display_path(path)); + } + let content = fs::read_to_string(&resolved) + .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 {}", + display_path(&path) + ) + })?; + 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') { + 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(); + } + 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 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(), + 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/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/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..15353ec 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)] @@ -86,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, @@ -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 {}", filetrail::completion::display_path(&path)); + } + 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/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 63f5d9d..0c86f91 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -10,9 +10,426 @@ 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; +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 = home.join("bin 'quoted' $cash \\files \"double\" `literal`"); + fs::create_dir(&bin).unwrap(); + let binary = bin.join("filetrail"); + copy_executable(Path::new(env!("CARGO_BIN_EXE_filetrail")), &binary); + Self { temp, home, binary } + } + + 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")) + .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 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(), + "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(); + 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(), 1); +} + +#[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"]), + ] { + 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()) + .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!(content.contains("\"$HOME/")); + assert!(!content.contains(f.home.to_str().unwrap())); + 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(), 3); +} + +#[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() { + 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 && ${_comps[filetrail-fixture]-} == $FILETRAIL_TEST_COMPLETION ]] && (( $+functions[_filetrail] ))", + ]) + .output() + .unwrap(); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output_text(output); + } + } +} + +#[test] +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"), + ("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 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}\nstderr: {stderr}" + ); + } +} + +#[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(); + let fake_bin = f.temp.path().join("fake-bin"); + fs::create_dir(&fake_bin).unwrap(); + let cargo = fake_bin.join("cargo"); + // 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 = |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("19").status.code(), Some(19)); + assert!(!f.home.join(".zshrc").exists()); + output_text(run("0")); + 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, @@ -254,7 +671,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