feat: install tab completion for bash, zsh, and fish (#16)
`completions install` generates the script from the same clap definition the binary parses with, so it cannot drift from `--help`, and writes it where each shell already looks. fish scans its own completions directory unprompted; bash and zsh get one marked `source` line appended to their rc file, because nothing puts a per-user directory on their search path by default. `completions uninstall` takes back exactly those lines and leaves the rest of the file alone.
This commit is contained in:
Generated
+10
@@ -497,6 +497,15 @@ dependencies = [
|
|||||||
"terminal_size",
|
"terminal_size",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -2478,6 +2487,7 @@ version = "8.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"directories",
|
"directories",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
|||||||
@@ -149,17 +149,18 @@ soulseek-rs shares list|add|remove|status|reindex
|
|||||||
soulseek-rs config path|list|get|set
|
soulseek-rs config path|list|get|set
|
||||||
soulseek-rs portmap # test automatic port mapping
|
soulseek-rs portmap # test automatic port mapping
|
||||||
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
|
soulseek-rs skills install|uninstall|list # teach a coding agent this CLI
|
||||||
|
soulseek-rs completions install|uninstall # tab completion for bash/zsh/fish
|
||||||
```
|
```
|
||||||
|
|
||||||
`whoami`, `config` and `shares list|add|remove` are the ones a script runs
|
`whoami`, `config` and `shares list|add|remove` are the ones a script runs
|
||||||
first: the config commands need no account at all, and `whoami` answers
|
first: the config commands need no account at all, and `whoami` answers
|
||||||
"are these credentials good and what am I offering" in one call.
|
"are these credentials good and what am I offering" in one call.
|
||||||
|
|
||||||
Eleven of these need no credentials, because they never touch the network:
|
Thirteen of these need no credentials, because they never touch the network:
|
||||||
`config path|list|get|set`, `shares list|add|remove`, `portmap` and
|
`config path|list|get|set`, `shares list|add|remove`, `portmap`,
|
||||||
`skills install|uninstall|list`. `shares status` and `shares reindex` are not
|
`skills install|uninstall|list` and `completions install|uninstall`.
|
||||||
among them: they report what the network will see, which means logging in and
|
`shares status` and `shares reindex` are not among them: they report what the
|
||||||
scanning the folders.
|
network will see, which means logging in and scanning the folders.
|
||||||
|
|
||||||
Every command emits records except the four that perform an action.
|
Every command emits records except the four that perform an action.
|
||||||
`room say`, `message send`, `shares add` and `shares remove` say nothing on
|
`room say`, `message send`, `shares add` and `shares remove` say nothing on
|
||||||
@@ -278,6 +279,30 @@ soulseek-rs skills install --dir ~/.config/some-agent/skills
|
|||||||
soulseek-rs skills install --dir .claude/skills # commit it with the repo
|
soulseek-rs skills install --dir .claude/skills # commit it with the repo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Shell completions
|
||||||
|
|
||||||
|
Tab completion is generated from the same `clap` definition the binary parses
|
||||||
|
with, so it never drifts from `--help`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
soulseek-rs completions install
|
||||||
|
```
|
||||||
|
|
||||||
|
That covers whichever of bash, zsh and fish this machine uses, writing each
|
||||||
|
script where that shell already looks — `~/.config/fish/completions` is scanned
|
||||||
|
unprompted, while bash and zsh also get one marked `source` line appended to
|
||||||
|
`~/.bashrc` or `~/.zshrc`, because nothing else puts a per-user directory on
|
||||||
|
their search path. Open a new shell and `soulseek-rs sh<TAB>` completes.
|
||||||
|
|
||||||
|
```
|
||||||
|
installed zsh /home/you/.local/share/zsh/site-functions/_soulseek-rs
|
||||||
|
installed fish /home/you/.config/fish/completions/soulseek-rs.fish
|
||||||
|
```
|
||||||
|
|
||||||
|
`completions uninstall` removes the scripts and takes back exactly the lines it
|
||||||
|
added, leaving the rest of your rc file alone. Pass `--shell bash` (repeatable)
|
||||||
|
to act on one shell rather than the ones detected.
|
||||||
|
|
||||||
### Sharing
|
### Sharing
|
||||||
|
|
||||||
`serve` is the mode that makes this client a source: it stays logged in, keeps
|
`serve` is the mode that makes this client a source: it stays logged in, keeps
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ workspace = true
|
|||||||
soulseek-rs-lib = { version = "8.0.0", path = "../soulseek-rs-lib" }
|
soulseek-rs-lib = { version = "8.0.0", path = "../soulseek-rs-lib" }
|
||||||
|
|
||||||
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
|
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
|
||||||
|
clap_complete = "4.6.7"
|
||||||
ratatui = "0.30.2"
|
ratatui = "0.30.2"
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ soulseek-rs message send|read
|
|||||||
soulseek-rs shares list|add|remove|status|reindex
|
soulseek-rs shares list|add|remove|status|reindex
|
||||||
soulseek-rs config path|list|get|set
|
soulseek-rs config path|list|get|set
|
||||||
soulseek-rs portmap # test automatic port mapping
|
soulseek-rs portmap # test automatic port mapping
|
||||||
|
soulseek-rs completions install # tab completion for bash/zsh/fish
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `--json` for newline-delimited JSON instead of tab-separated text, so
|
Add `--json` for newline-delimited JSON instead of tab-separated text, so
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ answered, which is a negative answer rather than an error.
|
|||||||
this skill file into the local agent directories, or remove it: `agent`,
|
this skill file into the local agent directories, or remove it: `agent`,
|
||||||
`path`, `action`.
|
`path`, `action`.
|
||||||
|
|
||||||
|
**`completions install`** and **`completions uninstall`** — write the tab
|
||||||
|
completion script where bash, zsh, or fish looks for it, or take it back
|
||||||
|
again: `shell`, `path`, `action`. For a human at a prompt, not for you.
|
||||||
|
|
||||||
## Idioms
|
## Idioms
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Examples:
|
|||||||
soulseek-rs serve --follow --json share files, stream upload events
|
soulseek-rs serve --follow --json share files, stream upload events
|
||||||
soulseek-rs user someuser is this peer online and worth it
|
soulseek-rs user someuser is this peer online and worth it
|
||||||
soulseek-rs skills install teach your coding agent this CLI
|
soulseek-rs skills install teach your coding agent this CLI
|
||||||
|
soulseek-rs completions install tab completion for your shell
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 success 2 usage/config 3 connect or login 4 no results
|
0 success 2 usage/config 3 connect or login 4 no results
|
||||||
@@ -198,6 +199,35 @@ pub enum Commands {
|
|||||||
/// Teach a local coding agent this command surface
|
/// Teach a local coding agent this command surface
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Skills(SkillsCommand),
|
Skills(SkillsCommand),
|
||||||
|
|
||||||
|
/// Tab completion for bash, zsh, and fish
|
||||||
|
#[command(subcommand)]
|
||||||
|
Completions(CompletionsCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum CompletionsCommand {
|
||||||
|
/// Write the completion script where the shell looks for it
|
||||||
|
Install(CompletionsArgs),
|
||||||
|
|
||||||
|
/// Remove the completion script again
|
||||||
|
Uninstall(CompletionsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct CompletionsArgs {
|
||||||
|
/// Shell to act on instead of the ones found on this machine; repeat for
|
||||||
|
/// several
|
||||||
|
#[arg(long, value_enum, action = ArgAction::Append)]
|
||||||
|
pub shell: Vec<Shell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shells with a conventional per-user completions location.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum Shell {
|
||||||
|
Bash,
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
//! Tab completion for the shell: `completions`.
|
||||||
|
//!
|
||||||
|
//! The script is generated from the same `clap` definition the binary parses
|
||||||
|
//! with, so it never drifts. Installing means putting it where the shell
|
||||||
|
//! already looks — which is enough on its own only for fish; bash and zsh each
|
||||||
|
//! get one `source` line appended to their rc file, marked so `uninstall` can
|
||||||
|
//! take back exactly what was added and nothing else.
|
||||||
|
|
||||||
|
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
|
||||||
|
use crate::output::{CliError, CliResult, CompletionRecord, Out};
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use directories::UserDirs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// The command being completed, and the file name the scripts are written as.
|
||||||
|
const BIN: &str = "soulseek-rs";
|
||||||
|
|
||||||
|
/// What every line this command writes into an rc file ends with, and the only
|
||||||
|
/// thing `uninstall` will delete from one.
|
||||||
|
const MARKER: &str = "# soulseek-rs completions";
|
||||||
|
|
||||||
|
/// The directories the install locations hang off, resolved once so the rest
|
||||||
|
/// of the module is a pure function of them.
|
||||||
|
struct Dirs {
|
||||||
|
home: PathBuf,
|
||||||
|
data: PathBuf,
|
||||||
|
config: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One shell's completion script, and the rc file that has to load it.
|
||||||
|
struct Target {
|
||||||
|
shell: Shell,
|
||||||
|
script: PathBuf,
|
||||||
|
/// `None` for fish, whose completions directory is scanned automatically.
|
||||||
|
rc: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
fn record(&self, action: &str) -> CompletionRecord {
|
||||||
|
CompletionRecord {
|
||||||
|
shell: self.shell.name().to_string(),
|
||||||
|
path: self.script.display().to_string(),
|
||||||
|
action: action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_line(&self) -> String {
|
||||||
|
format!("source \"{}\" {MARKER}", self.script.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Bash => "bash",
|
||||||
|
Self::Zsh => "zsh",
|
||||||
|
Self::Fish => "fish",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn generator(self) -> clap_complete::Shell {
|
||||||
|
match self {
|
||||||
|
Self::Bash => clap_complete::Shell::Bash,
|
||||||
|
Self::Zsh => clap_complete::Shell::Zsh,
|
||||||
|
Self::Fish => clap_complete::Shell::Fish,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult {
|
||||||
|
match command {
|
||||||
|
CompletionsCommand::Install(args) => install(out, &targets(args)?),
|
||||||
|
CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(out: &Out, targets: &[Target]) -> CliResult {
|
||||||
|
for target in targets {
|
||||||
|
let mut changed = write_script(&target.script, &script(target.shell))?;
|
||||||
|
if let Some(rc) = &target.rc {
|
||||||
|
changed |= add_source_line(rc, &target.source_line())?;
|
||||||
|
}
|
||||||
|
out.emit(&target.record(if changed {
|
||||||
|
"installed"
|
||||||
|
} else {
|
||||||
|
"unchanged"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
out.status("open a new shell to pick it up");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall(out: &Out, targets: &[Target]) -> CliResult {
|
||||||
|
for target in targets {
|
||||||
|
let mut changed = remove_script(&target.script)?;
|
||||||
|
if let Some(rc) = &target.rc {
|
||||||
|
changed |= remove_source_lines(rc)?;
|
||||||
|
}
|
||||||
|
out.emit(&target.record(if changed { "removed" } else { "absent" }));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The completion script as this binary's own argument parser defines it.
|
||||||
|
fn script(shell: Shell) -> Vec<u8> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
clap_complete::generate(
|
||||||
|
shell.generator(),
|
||||||
|
&mut Cli::command(),
|
||||||
|
BIN,
|
||||||
|
&mut buffer,
|
||||||
|
);
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which shells to act on. Naming shells aims the command at exactly those;
|
||||||
|
/// detection is what answers the question when nothing does.
|
||||||
|
fn targets(args: &CompletionsArgs) -> CliResult<Vec<Target>> {
|
||||||
|
let dirs = Dirs::detect()?;
|
||||||
|
let shells = if args.shell.is_empty() {
|
||||||
|
let found = detected(&dirs, std::env::var("SHELL").ok().as_deref());
|
||||||
|
if found.is_empty() {
|
||||||
|
return Err(CliError::no_results(
|
||||||
|
"no bash, zsh, or fish setup found in your home directory; \
|
||||||
|
name one with --shell",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
found
|
||||||
|
} else {
|
||||||
|
args.shell.clone()
|
||||||
|
};
|
||||||
|
Ok(shells
|
||||||
|
.into_iter()
|
||||||
|
.map(|shell| target(shell, &dirs))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where each shell looks. bash and zsh both read a per-user directory that
|
||||||
|
/// nothing puts on the search path by default, hence the rc line; fish scans
|
||||||
|
/// its own completions directory unprompted.
|
||||||
|
fn target(shell: Shell, dirs: &Dirs) -> Target {
|
||||||
|
let (script, rc) = match shell {
|
||||||
|
Shell::Bash => (
|
||||||
|
dirs.data.join("bash-completion/completions").join(BIN),
|
||||||
|
Some(bash_rc(&dirs.home)),
|
||||||
|
),
|
||||||
|
Shell::Zsh => (
|
||||||
|
dirs.data.join("zsh/site-functions").join(format!("_{BIN}")),
|
||||||
|
Some(dirs.home.join(".zshrc")),
|
||||||
|
),
|
||||||
|
Shell::Fish => (
|
||||||
|
dirs.config
|
||||||
|
.join("fish/completions")
|
||||||
|
.join(format!("{BIN}.fish")),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Target { shell, script, rc }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shell counts as present when it has left configuration behind, or when it
|
||||||
|
/// is the one the user is logged into — a fresh macOS zsh account has no
|
||||||
|
/// `.zshrc` at all until something writes one.
|
||||||
|
fn detected(dirs: &Dirs, login_shell: Option<&str>) -> Vec<Shell> {
|
||||||
|
let running =
|
||||||
|
|name: &str| login_shell.is_some_and(|path| path.ends_with(name));
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Shell::Bash,
|
||||||
|
dirs.home.join(".bashrc").exists()
|
||||||
|
|| dirs.home.join(".bash_profile").exists()
|
||||||
|
|| running("bash"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Shell::Zsh,
|
||||||
|
dirs.home.join(".zshrc").exists() || running("zsh"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Shell::Fish,
|
||||||
|
dirs.config.join("fish").is_dir() || running("fish"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(shell, present)| present.then_some(shell))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive bash reads `.bashrc`, except on machines where only the login
|
||||||
|
/// file exists — which is the common macOS shape.
|
||||||
|
fn bash_rc(home: &Path) -> PathBuf {
|
||||||
|
let profile = home.join(".bash_profile");
|
||||||
|
let rc = home.join(".bashrc");
|
||||||
|
if !rc.exists() && profile.exists() {
|
||||||
|
profile
|
||||||
|
} else {
|
||||||
|
rc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dirs {
|
||||||
|
fn detect() -> CliResult<Self> {
|
||||||
|
let home = UserDirs::new()
|
||||||
|
.map(|dirs| dirs.home_dir().to_path_buf())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CliError::usage("cannot find your home directory")
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
data: xdg("XDG_DATA_HOME", &home, ".local/share"),
|
||||||
|
config: xdg("XDG_CONFIG_HOME", &home, ".config"),
|
||||||
|
home,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shells follow the XDG layout on every platform, unlike this client's own
|
||||||
|
/// config, which follows each platform's convention (see `persist::paths`).
|
||||||
|
fn xdg(variable: &str, home: &Path, fallback: &str) -> PathBuf {
|
||||||
|
std::env::var_os(variable)
|
||||||
|
.filter(|dir| !dir.is_empty())
|
||||||
|
.map_or_else(|| home.join(fallback), PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_script(path: &Path, script: &[u8]) -> CliResult<bool> {
|
||||||
|
if std::fs::read(path).is_ok_and(|found| found == script) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
create_parent(path)?;
|
||||||
|
std::fs::write(path, script).map_err(|e| unwritable(path, &e))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_script(path: &Path) -> CliResult<bool> {
|
||||||
|
match std::fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(e) => Err(unwritable(path, &e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_source_line(rc: &Path, line: &str) -> CliResult<bool> {
|
||||||
|
let existing = std::fs::read_to_string(rc).unwrap_or_default();
|
||||||
|
if existing.lines().any(|found| found == line) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
create_parent(rc)?;
|
||||||
|
let separator = if existing.is_empty() || existing.ends_with('\n') {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"\n"
|
||||||
|
};
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(rc)
|
||||||
|
.map_err(|e| unwritable(rc, &e))?;
|
||||||
|
writeln!(file, "{separator}{line}").map_err(|e| unwritable(rc, &e))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take back only the marked lines. Somebody else's rc file is a bad thing to
|
||||||
|
/// rewrite from memory, so every other line is preserved verbatim.
|
||||||
|
fn remove_source_lines(rc: &Path) -> CliResult<bool> {
|
||||||
|
let Ok(existing) = std::fs::read_to_string(rc) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let kept: Vec<&str> = existing
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.ends_with(MARKER))
|
||||||
|
.collect();
|
||||||
|
if kept.len() == existing.lines().count() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut rewritten = kept.join("\n");
|
||||||
|
if !rewritten.is_empty() {
|
||||||
|
rewritten.push('\n');
|
||||||
|
}
|
||||||
|
std::fs::write(rc, rewritten).map_err(|e| unwritable(rc, &e))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_parent(path: &Path) -> CliResult {
|
||||||
|
match path.parent() {
|
||||||
|
Some(parent) => {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| unwritable(parent, &e))
|
||||||
|
}
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
|
||||||
|
CliError::usage(format!("cannot write {}: {error}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn dirs(home: &Path) -> Dirs {
|
||||||
|
Dirs {
|
||||||
|
home: home.to_path_buf(),
|
||||||
|
data: home.join(".local/share"),
|
||||||
|
config: home.join(".config"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rc line only works because sourcing the script registers the
|
||||||
|
/// completion rather than just defining a function. zsh is the one that
|
||||||
|
/// could plausibly go the other way: an autoload-style `#compdef` file does
|
||||||
|
/// nothing on its own, so the install rests on the footer clap emits.
|
||||||
|
#[test]
|
||||||
|
fn a_sourced_script_registers_the_command() {
|
||||||
|
let zsh = String::from_utf8(script(Shell::Zsh)).expect("utf-8");
|
||||||
|
assert!(zsh.contains("compdef _soulseek-rs soulseek-rs"));
|
||||||
|
let bash = String::from_utf8(script(Shell::Bash)).expect("utf-8");
|
||||||
|
assert!(bash.contains("complete -F _soulseek-rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_shell_lands_where_it_already_looks() {
|
||||||
|
let home = PathBuf::from("/home/me");
|
||||||
|
let dirs = dirs(&home);
|
||||||
|
for (shell, script, rc) in [
|
||||||
|
(
|
||||||
|
Shell::Bash,
|
||||||
|
".local/share/bash-completion/completions/soulseek-rs",
|
||||||
|
Some(".bashrc"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Shell::Zsh,
|
||||||
|
".local/share/zsh/site-functions/_soulseek-rs",
|
||||||
|
Some(".zshrc"),
|
||||||
|
),
|
||||||
|
// fish scans its own completions directory, so it needs no rc line.
|
||||||
|
(
|
||||||
|
Shell::Fish,
|
||||||
|
".config/fish/completions/soulseek-rs.fish",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let target = target(shell, &dirs);
|
||||||
|
assert_eq!(target.script, home.join(script));
|
||||||
|
assert_eq!(target.rc, rc.map(|file| home.join(file)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_shells_this_machine_uses_become_targets() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let dirs = dirs(home.path());
|
||||||
|
assert!(detected(&dirs, None).is_empty());
|
||||||
|
assert_eq!(detected(&dirs, Some("/bin/zsh")), [Shell::Zsh]);
|
||||||
|
|
||||||
|
std::fs::write(home.path().join(".bashrc"), "").expect("bashrc");
|
||||||
|
std::fs::create_dir_all(dirs.config.join("fish")).expect("fish");
|
||||||
|
assert_eq!(detected(&dirs, None), [Shell::Bash, Shell::Fish]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bash_falls_back_to_the_login_file_when_that_is_all_there_is() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
assert_eq!(bash_rc(home.path()), home.path().join(".bashrc"));
|
||||||
|
|
||||||
|
std::fs::write(home.path().join(".bash_profile"), "").expect("write");
|
||||||
|
assert_eq!(bash_rc(home.path()), home.path().join(".bash_profile"));
|
||||||
|
|
||||||
|
std::fs::write(home.path().join(".bashrc"), "").expect("write");
|
||||||
|
assert_eq!(bash_rc(home.path()), home.path().join(".bashrc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installing_twice_adds_one_line_and_uninstalling_takes_only_that() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let rc = home.path().join(".zshrc");
|
||||||
|
std::fs::write(&rc, "export EDITOR=vi").expect("rc");
|
||||||
|
let target = target(Shell::Zsh, &dirs(home.path()));
|
||||||
|
let line = target.source_line();
|
||||||
|
|
||||||
|
assert!(add_source_line(&rc, &line).expect("add"));
|
||||||
|
assert!(!add_source_line(&rc, &line).expect("add again"));
|
||||||
|
let after = std::fs::read_to_string(&rc).expect("read");
|
||||||
|
assert_eq!(after, format!("export EDITOR=vi\n{line}\n"));
|
||||||
|
|
||||||
|
assert!(remove_source_lines(&rc).expect("remove"));
|
||||||
|
assert!(!remove_source_lines(&rc).expect("remove again"));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&rc).expect("read"),
|
||||||
|
"export EDITOR=vi\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_script_is_written_once_and_removed_once() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = target(Shell::Fish, &dirs(home.path())).script;
|
||||||
|
let fish = script(Shell::Fish);
|
||||||
|
|
||||||
|
assert!(write_script(&path, &fish).expect("write"));
|
||||||
|
assert!(!write_script(&path, &fish).expect("write again"));
|
||||||
|
assert_eq!(std::fs::read(&path).expect("read"), fish);
|
||||||
|
|
||||||
|
assert!(remove_script(&path).expect("remove"));
|
||||||
|
assert!(!remove_script(&path).expect("remove again"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! first; the ones that only read the config file or the local machine are
|
//! first; the ones that only read the config file or the local machine are
|
||||||
//! dispatched by `main::run` before any credentials are demanded.
|
//! dispatched by `main::run` before any credentials are demanded.
|
||||||
|
|
||||||
|
pub mod completions;
|
||||||
pub mod peer;
|
pub mod peer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
@@ -196,7 +197,10 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
|
|||||||
// These need no account, so main.rs answers them before asking for
|
// These need no account, so main.rs answers them before asking for
|
||||||
// credentials; the arms exist only to keep the match exhaustive.
|
// credentials; the arms exist only to keep the match exhaustive.
|
||||||
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
||||||
Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) => {
|
Commands::Config(_)
|
||||||
|
| Commands::Shares(_)
|
||||||
|
| Commands::Skills(_)
|
||||||
|
| Commands::Completions(_) => {
|
||||||
Err(CliError::usage("handled without a session; see main::run"))
|
Err(CliError::usage("handled without a session; see main::run"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
Commands::Skills(ref command) => {
|
Commands::Skills(ref command) => {
|
||||||
return commands::skills::run(out, command);
|
return commands::skills::run(out, command);
|
||||||
}
|
}
|
||||||
|
Commands::Completions(ref command) => {
|
||||||
|
return commands::completions::run(out, command);
|
||||||
|
}
|
||||||
Commands::Config(ConfigCommand::Path) => {
|
Commands::Config(ConfigCommand::Path) => {
|
||||||
return commands::settings::config_path(out, &store);
|
return commands::settings::config_path(out, &store);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,6 +445,28 @@ impl Record for SkillRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What happened to one shell's completion script.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct CompletionRecord {
|
||||||
|
/// `bash`, `zsh`, or `fish`.
|
||||||
|
pub shell: String,
|
||||||
|
pub path: String,
|
||||||
|
/// `installed` (written), `unchanged` (already identical), `removed`, or
|
||||||
|
/// `absent`.
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record for CompletionRecord {
|
||||||
|
fn text(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}\t{}\t{}",
|
||||||
|
sanitize(&self.action),
|
||||||
|
sanitize(&self.shell),
|
||||||
|
sanitize(&self.path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The result of probing automatic port mapping.
|
/// The result of probing automatic port mapping.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct PortmapRecord {
|
pub struct PortmapRecord {
|
||||||
|
|||||||
@@ -93,8 +93,15 @@ fn help_lists_every_scriptable_command() {
|
|||||||
assert_eq!(code(&output), EXIT_OK);
|
assert_eq!(code(&output), EXIT_OK);
|
||||||
let help = stdout(&output);
|
let help = stdout(&output);
|
||||||
for command in [
|
for command in [
|
||||||
"search", "download", "get", "browse", "room", "message", "portmap",
|
"search",
|
||||||
|
"download",
|
||||||
|
"get",
|
||||||
|
"browse",
|
||||||
|
"room",
|
||||||
|
"message",
|
||||||
|
"portmap",
|
||||||
"skills",
|
"skills",
|
||||||
|
"completions",
|
||||||
] {
|
] {
|
||||||
assert!(help.contains(command), "--help should mention {command}");
|
assert!(help.contains(command), "--help should mention {command}");
|
||||||
}
|
}
|
||||||
@@ -333,6 +340,51 @@ fn listing_skills_reports_without_writing_anything() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// fish is the shell the whole round trip can be driven for on every platform:
|
||||||
|
/// its completions directory hangs off `XDG_CONFIG_HOME`, so nothing outside
|
||||||
|
/// the scratch directory is read or written. bash and zsh also append a line
|
||||||
|
/// to a real rc file under `$HOME`, which is not a thing to point a test at on
|
||||||
|
/// Windows — the unit tests cover that half.
|
||||||
|
#[test]
|
||||||
|
fn installing_completions_is_repeatable_and_reversible() {
|
||||||
|
let target = Scratch::new("completions");
|
||||||
|
let installed = target
|
||||||
|
.path()
|
||||||
|
.join("fish")
|
||||||
|
.join("completions")
|
||||||
|
.join("soulseek-rs.fish");
|
||||||
|
let completions = |verb: &str| {
|
||||||
|
command(&["--json", "completions", verb, "--shell", "fish"])
|
||||||
|
.env("XDG_CONFIG_HOME", target.path())
|
||||||
|
.output()
|
||||||
|
.expect("the binary should run")
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = completions("install");
|
||||||
|
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
|
||||||
|
assert_eq!(action(&first), "installed");
|
||||||
|
let script = std::fs::read_to_string(&installed).expect("a fish script");
|
||||||
|
assert!(script.contains("complete -c soulseek-rs"));
|
||||||
|
assert!(
|
||||||
|
script.contains("-l download-dir"),
|
||||||
|
"flags are completed too"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action(&completions("install")),
|
||||||
|
"unchanged",
|
||||||
|
"reinstalling is how you update"
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = completions("uninstall");
|
||||||
|
assert_eq!(action(&removed), "removed");
|
||||||
|
assert!(!installed.exists());
|
||||||
|
|
||||||
|
let again = completions("uninstall");
|
||||||
|
assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure");
|
||||||
|
assert_eq!(action(&again), "absent");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server required (soulfind)
|
// Server required (soulfind)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -181,14 +181,19 @@ export const Docs = () => {
|
|||||||
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
|
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
|
||||||
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
|
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
|
||||||
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
|
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
|
||||||
<Code>portmap</Code>, <Code>skills</Code>. Records are tab-separated fields,
|
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records
|
||||||
one per line, or NDJSON with <Code>--json</Code>.
|
are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
|
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
|
||||||
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
|
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
|
||||||
nothing, 3 could not reach the server, 6 the transfer died.
|
nothing, 3 could not reach the server, 6 the transfer died.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>completions install</Code> gives bash, zsh and fish tab completion for
|
||||||
|
all of it, generated from the same definition the binary parses with.{' '}
|
||||||
|
<Code>completions uninstall</Code> takes it back out.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Code>portmap</Code> tests whether your router will let peers connect back,
|
<Code>portmap</Code> tests whether your router will let peers connect back,
|
||||||
worth running once.{' '}
|
worth running once.{' '}
|
||||||
|
|||||||
@@ -301,6 +301,12 @@ export const Install = () => {
|
|||||||
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
||||||
instead.
|
instead.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
<Code>soulseek-rs completions install</Code> then adds tab completion to
|
||||||
|
whichever of bash, zsh and fish this machine uses — generated from the
|
||||||
|
same definition the binary parses with, so it never drifts from{' '}
|
||||||
|
<Code>--help</Code>. Open a new shell to pick it up.
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,13 +525,17 @@ export const Install = () => {
|
|||||||
|
|
||||||
<Section band>
|
<Section band>
|
||||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||||
Three things exist on disk: the binary, the config, and the state. Nothing else,
|
Three things exist on disk: the binary, the config, and the state — plus
|
||||||
|
the completion scripts and the agent skill, if you asked for those. Nothing else,
|
||||||
unless you point <Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
unless you point <Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<Cols start>
|
<Cols start>
|
||||||
<Terminal
|
<Terminal
|
||||||
label="macOS · Linux"
|
label="macOS · Linux"
|
||||||
lines={[
|
lines={[
|
||||||
|
{ t: 'cm', text: '# while the binary is still there' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs completions uninstall' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
|
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
|
||||||
{
|
{
|
||||||
@@ -539,6 +549,7 @@ export const Install = () => {
|
|||||||
<Terminal
|
<Terminal
|
||||||
label="Windows · PowerShell"
|
label="Windows · PowerShell"
|
||||||
lines={[
|
lines={[
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
{
|
{
|
||||||
t: 'cmd',
|
t: 'cmd',
|
||||||
|
|||||||
Reference in New Issue
Block a user