Skip to content

Commit 26b941e

Browse files
committed
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.
1 parent ec5c592 commit 26b941e

9 files changed

Lines changed: 640 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ jobs:
5050
run: cargo check --locked --all-targets
5151
- name: Clippy (warnings are errors)
5252
run: cargo clippy --locked --all-targets -- -D warnings
53+
- name: Install shells for completion tests (Linux)
54+
if: runner.os == 'Linux'
55+
run: sudo apt-get update && sudo apt-get install -y zsh fish
56+
- name: Install Fish for completion tests (macOS)
57+
if: runner.os == 'macOS'
58+
run: command -v fish >/dev/null || brew install fish
5359
- name: Unit and integration tests
5460
run: cargo test --locked --all-targets
5561
- name: Documentation tests

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ one executable for macOS and Linux. Read README.md before changing its behavior.
1313
does not require a separate Rust or Git installation.
1414
- Run `cargo fmt --all`, `cargo clippy --locked --all-targets -- -D warnings`,
1515
`cargo test --locked --all-targets`, and `cargo test --locked --doc`.
16+
- Completion integration tests require Bash, Zsh, and Fish on PATH.
1617
- Run `taplo fmt` and `taplo fmt --check` with taplo-cli 0.10.0.
1718
- Every Rust import must be its own `use` statement. Do not use grouped braces.
1819
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.
6162
All disk mutations share operation.lock; daemon.lock prevents duplicate daemons.
6263
CLI config edits are atomic and picked up by the daemon without restarting it.
6364
- service.rs renders/installs user-level launchd or systemd definitions.
65+
- completion.rs installs explicitly requested Bash, Zsh, and Fish completion hooks.
66+
Keep generation derived from the Clap command tree, including nested commands.
67+
Generation and installation must work before init without creating application data.
68+
Preserve existing shell configuration, symlinks, and permissions; replace only
69+
FileTrail's marked block and refuse malformed markers. Respect ZDOTDIR and
70+
XDG_CONFIG_HOME. Hooks invoke the absolute executable path with shell-specific
71+
quoting, so upgrades at the same location update completion automatically.
72+
install.sh wraps cargo install followed by completion installation. Never use
73+
build.rs to modify shell configuration during builds. Test with isolated HOME,
74+
ZDOTDIR, and XDG_CONFIG_HOME; never modify the developer's real shell profiles.
6475
- Default synchronization preserves deleted source files in the destination.
6576
Opt-in deletion applies only to previously synchronized paths. A missing source
6677
root directory must never trigger mass deletion.

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,51 @@ cargo install --path . --locked
1919
filetrail --help
2020
```
2121

22+
To install FileTrail and enable Tab completion in one step:
23+
24+
```sh
25+
./install.sh
26+
```
27+
28+
The script detects Bash, Zsh, or Fish from `$SHELL`. You can select one explicitly
29+
with `./install.sh zsh`. It installs with Cargo, then configures that shell's
30+
completion. Open a new shell afterward. The installation root defaults to
31+
`${CARGO_HOME:-$HOME/.cargo}`; set `CARGO_INSTALL_ROOT` to override it.
32+
33+
## Tab completion
34+
35+
If you installed FileTrail with `cargo install`, enable completion with:
36+
37+
```sh
38+
filetrail completions --install
39+
```
40+
41+
This detects your shell from `$SHELL`. To select a shell explicitly:
42+
43+
```sh
44+
filetrail completions zsh --install
45+
filetrail completions bash --install
46+
filetrail completions fish --install
47+
```
48+
49+
Run the command for the shell you use, then open a new shell. Tab completes
50+
subcommands (including `daemon` and `service` actions), options, and file paths.
51+
For example, try `filetrail da<Tab>`, `filetrail daemon st<Tab>`, or
52+
`filetrail add --f<Tab>`.
53+
54+
Installation preserves existing shell configuration and is safe to repeat. It
55+
uses `.zshrc` (respecting `ZDOTDIR`), `.bashrc` and Bash's active login profile,
56+
or Fish's completion directory (respecting `XDG_CONFIG_HOME`). Completion stays
57+
in sync when you upgrade the executable at the same location. Run installation
58+
again if you move it. To remove completion, delete the marked FileTrail block
59+
from the configured files printed by the install command.
60+
61+
To print a completion script for manual setup, omit `--install`:
62+
63+
```sh
64+
filetrail completions zsh
65+
```
66+
2267
## Get started
2368

2469
```sh
@@ -198,7 +243,6 @@ filetrail doctor
198243
filetrail logs --follow
199244
filetrail --help
200245
filetrail add --help
201-
filetrail completions zsh
202246
```
203247

204248
For development instructions, see [AGENTS.md](AGENTS.md).

README_zh.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,48 @@ cargo install --path . --locked
1717
filetrail --help
1818
```
1919

20+
如需一次完成 FileTrail 安装和 Tab 补全配置:
21+
22+
```sh
23+
./install.sh
24+
```
25+
26+
脚本根据 `$SHELL` 识别 Bash、Zsh 或 Fish,也可以用 `./install.sh zsh` 显式指定。
27+
它先通过 Cargo 安装,再配置所选 shell 的补全,完成后重新打开 shell 即可。
28+
安装根目录默认为 `${CARGO_HOME:-$HOME/.cargo}`,可通过 `CARGO_INSTALL_ROOT` 覆盖。
29+
30+
## Tab 补全
31+
32+
如果使用 `cargo install` 安装 FileTrail,执行以下命令启用补全:
33+
34+
```sh
35+
filetrail completions --install
36+
```
37+
38+
该命令根据 `$SHELL` 识别 shell,也可以显式指定:
39+
40+
```sh
41+
filetrail completions zsh --install
42+
filetrail completions bash --install
43+
filetrail completions fish --install
44+
```
45+
46+
执行你所用 shell 对应的命令,然后重新打开 shell。Tab 可以补全子命令
47+
(包括 `daemon``service` 的操作)、选项及文件路径。例如:
48+
`filetrail da<Tab>``filetrail daemon st<Tab>``filetrail add --f<Tab>`
49+
50+
安装会保留已有 shell 配置,重复执行不会添加重复配置。配置位置为 `.zshrc`
51+
(遵循 `ZDOTDIR`)、`.bashrc` 和 Bash 当前使用的登录配置文件,或 Fish 的补全目录
52+
(遵循 `XDG_CONFIG_HOME`)。在相同位置升级可执行文件后,补全会同步更新;
53+
移动可执行文件后需重新安装补全。若要移除补全,删除安装命令所列配置文件中
54+
带有 FileTrail 标记的配置块即可。
55+
56+
如需手动配置,可省略 `--install`,只输出补全脚本:
57+
58+
```sh
59+
filetrail completions zsh
60+
```
61+
2062
## 开始使用
2163

2264
```sh
@@ -184,7 +226,6 @@ filetrail doctor
184226
filetrail logs --follow
185227
filetrail --help
186228
filetrail add --help
187-
filetrail completions zsh
188229
```
189230

190231
开发说明见 [AGENTS.md](AGENTS.md)

install.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
usage() {
5+
echo 'Usage: ./install.sh [bash|zsh|fish]'
6+
echo 'Install FileTrail with Cargo and configure Tab completion (defaults to $SHELL).'
7+
echo 'CARGO_INSTALL_ROOT overrides the installation root; otherwise CARGO_HOME or ~/.cargo is used.'
8+
}
9+
10+
if [ "$#" -gt 1 ]; then
11+
usage >&2
12+
exit 2
13+
fi
14+
case "${1-}" in
15+
-h|--help) usage; exit 0 ;;
16+
esac
17+
filetrail_shell=${SHELL-}
18+
filetrail_shell=${1:-${filetrail_shell##*/}}
19+
case "$filetrail_shell" in
20+
bash|zsh|fish) ;;
21+
*) echo 'Specify bash, zsh, or fish: ./install.sh zsh' >&2; exit 2 ;;
22+
esac
23+
24+
filetrail_project=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
25+
filetrail_install_root=${CARGO_INSTALL_ROOT:-${CARGO_HOME:-"$HOME/.cargo"}}
26+
mkdir -p "$filetrail_install_root"
27+
filetrail_install_root=$(CDPATH= cd -- "$filetrail_install_root" && pwd)
28+
cd "$filetrail_project"
29+
cargo install --path . --locked --root "$filetrail_install_root"
30+
"$filetrail_install_root/bin/filetrail" completions "$filetrail_shell" --install

src/completion.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
use std::env;
2+
use std::fs;
3+
use std::io::ErrorKind;
4+
use std::io::Write;
5+
use std::path::Path;
6+
use std::path::PathBuf;
7+
8+
use anyhow::Context;
9+
use anyhow::Result;
10+
use anyhow::bail;
11+
use clap_complete::Shell;
12+
13+
const BEGIN: &str = "# >>> filetrail completions >>>";
14+
const END: &str = "# <<< filetrail completions <<<";
15+
16+
/// Install startup hooks without requiring an initialized repository or data directory.
17+
/// Hooks ask the installed binary for current definitions, so upgrades need no regeneration.
18+
pub fn install(shell: Shell, binary: &Path) -> Result<Vec<PathBuf>> {
19+
let hook = hook(shell, binary)?;
20+
let home = dirs::home_dir().context("cannot determine home directory")?;
21+
let paths = match shell {
22+
Shell::Bash => {
23+
// Bash reads .bashrc for interactive shells and the first available
24+
// login profile for login shells (including macOS Terminal).
25+
let mut login = home.join(".bash_profile");
26+
for name in [".bash_profile", ".bash_login", ".profile"] {
27+
let candidate = home.join(name);
28+
match fs::symlink_metadata(&candidate) {
29+
Ok(_) => {
30+
login = candidate;
31+
break;
32+
}
33+
Err(error) if error.kind() == ErrorKind::NotFound => {}
34+
Err(error) => return Err(error.into()),
35+
}
36+
}
37+
vec![home.join(".bashrc"), login]
38+
}
39+
Shell::Zsh => vec![environment_directory("ZDOTDIR", &home).join(".zshrc")],
40+
Shell::Fish => vec![
41+
environment_directory("XDG_CONFIG_HOME", &home.join(".config"))
42+
.join("fish/completions/filetrail.fish"),
43+
],
44+
_ => bail!("automatic installation supports bash, zsh, and fish only"),
45+
};
46+
47+
// Validate every file before writing any of them. Resolve existing symlinks
48+
// so common dotfile setups retain both their symlinks and file permissions.
49+
let updates = paths
50+
.iter()
51+
.map(|path| prepare_update(path, &hook))
52+
.collect::<Result<Vec<_>>>()?;
53+
for (path, content, permissions) in updates {
54+
let parent = path
55+
.parent()
56+
.context("missing shell configuration parent")?;
57+
fs::create_dir_all(parent)?;
58+
let mut file = tempfile::NamedTempFile::new_in(parent)?;
59+
file.write_all(content.as_bytes())?;
60+
if let Some(permissions) = permissions {
61+
file.as_file().set_permissions(permissions)?;
62+
}
63+
file.as_file().sync_all()?;
64+
file.persist(&path)
65+
.with_context(|| format!("cannot update {}", path.display()))?;
66+
}
67+
Ok(paths)
68+
}
69+
70+
fn environment_directory(name: &str, fallback: &Path) -> PathBuf {
71+
env::var_os(name)
72+
.filter(|value| !value.is_empty())
73+
.map(PathBuf::from)
74+
.unwrap_or_else(|| fallback.to_owned())
75+
}
76+
77+
fn hook(shell: Shell, binary: &Path) -> Result<String> {
78+
let binary = binary
79+
.to_str()
80+
.context("executable path must be valid UTF-8")?;
81+
let quoted = match shell {
82+
Shell::Fish => format!("'{}'", binary.replace('\\', "\\\\").replace('\'', "\\'")),
83+
_ => format!("'{}'", binary.replace('\'', "'\\''")),
84+
};
85+
let body = match shell {
86+
Shell::Bash => format!(
87+
"if [ -n \"${{BASH_VERSION-}}\" ] && [ -x {quoted} ]; then\n\
88+
case $- in\n\
89+
*i*) eval \"$({quoted} completions bash)\" ;;\n\
90+
esac\n\
91+
fi\n"
92+
),
93+
Shell::Zsh => format!(
94+
"if [[ -o interactive && -x {quoted} ]]; then\n\
95+
if (( ! $+functions[compdef] )); then\n\
96+
autoload -Uz compinit\n\
97+
compinit\n\
98+
fi\n\
99+
eval \"$({quoted} completions zsh)\"\n\
100+
fi\n"
101+
),
102+
Shell::Fish => format!(
103+
"if test -x {quoted}\n\
104+
{quoted} completions fish | source\n\
105+
end\n"
106+
),
107+
_ => bail!("automatic installation supports bash, zsh, and fish only"),
108+
};
109+
Ok(format!("{BEGIN}\n{body}{END}\n"))
110+
}
111+
112+
fn prepare_update(path: &Path, hook: &str) -> Result<(PathBuf, String, Option<fs::Permissions>)> {
113+
let (path, original, permissions) = match fs::symlink_metadata(path) {
114+
Ok(_) => {
115+
let resolved = fs::canonicalize(path)
116+
.with_context(|| format!("cannot resolve {}", path.display()))?;
117+
let metadata = fs::metadata(&resolved)?;
118+
if !metadata.is_file() {
119+
bail!("{} is not a regular file", path.display());
120+
}
121+
let content = fs::read_to_string(&resolved)
122+
.with_context(|| format!("cannot read {}", path.display()))?;
123+
(resolved, content, Some(metadata.permissions()))
124+
}
125+
Err(error) if error.kind() == ErrorKind::NotFound => (path.to_owned(), String::new(), None),
126+
Err(error) => return Err(error.into()),
127+
};
128+
let updated = replace_hook(&original, hook)
129+
.with_context(|| format!("invalid FileTrail completion block in {}", path.display()))?;
130+
Ok((path, updated, permissions))
131+
}
132+
133+
fn replace_hook(original: &str, hook: &str) -> Result<String> {
134+
let mut begin = None;
135+
let mut end = None;
136+
let mut offset = 0;
137+
for line in original.split_inclusive('\n') {
138+
match line.trim_end_matches(['\r', '\n']) {
139+
BEGIN if begin.is_none() && end.is_none() => begin = Some(offset),
140+
END if begin.is_some() && end.is_none() => end = Some(offset + line.len()),
141+
BEGIN | END => {
142+
bail!("duplicate or out-of-order markers; repair the marked block first")
143+
}
144+
_ => {}
145+
}
146+
offset += line.len();
147+
}
148+
match (begin, end) {
149+
(Some(begin), Some(end)) => Ok(format!("{}{hook}{}", &original[..begin], &original[end..])),
150+
(None, None) => {
151+
let separator = if original.is_empty() || original.ends_with('\n') {
152+
""
153+
} else {
154+
"\n"
155+
};
156+
Ok(format!("{original}{separator}{hook}"))
157+
}
158+
_ => bail!("incomplete markers; repair the marked block first"),
159+
}
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::BEGIN;
165+
use super::END;
166+
use super::replace_hook;
167+
168+
#[test]
169+
fn replaces_only_its_own_block_and_preserves_surrounding_content() {
170+
let hook = format!("{BEGIN}\nnew\n{END}\n");
171+
let original = format!("before\n{BEGIN}\nold\n{END}\nafter\n");
172+
let updated = replace_hook(&original, &hook).unwrap();
173+
assert_eq!(updated, format!("before\n{hook}after\n"));
174+
assert_eq!(replace_hook(&updated, &hook).unwrap(), updated);
175+
assert_eq!(
176+
replace_hook("no newline", &hook).unwrap(),
177+
format!("no newline\n{hook}")
178+
);
179+
for broken in [
180+
format!("{BEGIN}\n"),
181+
format!("{END}\n"),
182+
format!("{hook}{hook}"),
183+
] {
184+
assert!(replace_hook(&broken, &hook).is_err());
185+
}
186+
}
187+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#[cfg(not(unix))]
44
compile_error!("Filetrail currently supports macOS and Linux only");
55

6+
pub mod completion;
67
pub mod config;
78
pub mod daemon;
89
pub mod git;

0 commit comments

Comments
 (0)