use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerOutcome {
Selected(String),
Cancelled,
Unavailable,
}
#[derive(Debug, Clone)]
pub struct FzfOpts {
pub prompt: String,
pub with_nth: String,
pub delimiter: String,
pub height: String,
pub extra_args: Vec<String>,
}
impl Default for FzfOpts {
fn default() -> Self {
Self {
prompt: "select > ".to_string(),
with_nth: "2..".to_string(),
delimiter: "\t".to_string(),
height: "40%".to_string(),
extra_args: vec!["--reverse".to_string(), "--no-multi".to_string()],
}
}
}
pub fn fzf_available() -> bool {
Command::new("fzf")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
const FZF_EXIT_NO_MATCH: i32 = 1;
const FZF_EXIT_INTERRUPTED: i32 = 130;
pub fn run_fzf(rows: &[String], opts: &FzfOpts) -> PickerOutcome {
use std::io::Write;
if rows.is_empty() {
return PickerOutcome::Unavailable;
}
let mut cmd = Command::new("fzf");
cmd.arg("--prompt")
.arg(&opts.prompt)
.arg("--with-nth")
.arg(&opts.with_nth)
.arg("--delimiter")
.arg(&opts.delimiter)
.arg("--height")
.arg(&opts.height);
for extra in &opts.extra_args {
cmd.arg(extra);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(_) => return PickerOutcome::Unavailable,
};
if let Some(mut stdin) = child.stdin.take() {
let payload = rows.join("\n");
let _ = stdin.write_all(payload.as_bytes());
let _ = stdin.write_all(b"\n");
}
let output = match child.wait_with_output() {
Ok(o) => o,
Err(_) => return PickerOutcome::Unavailable,
};
match output.status.code() {
Some(0) => {}
Some(FZF_EXIT_NO_MATCH) | Some(FZF_EXIT_INTERRUPTED) => return PickerOutcome::Cancelled,
_ => return PickerOutcome::Unavailable,
}
let selected = String::from_utf8_lossy(&output.stdout);
let line = match selected.lines().next() {
Some(l) if !l.is_empty() => l,
_ => return PickerOutcome::Unavailable,
};
let delim = opts.delimiter.chars().next().unwrap_or('\t');
PickerOutcome::Selected(line.split(delim).next().unwrap_or(line).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fzf_available_is_stable() {
let a = fzf_available();
let b = fzf_available();
assert_eq!(
a, b,
"fzf_available() must be deterministic within one process"
);
}
#[test]
fn fzf_available_returns_bool() {
let result: bool = fzf_available();
let _ = result;
}
#[test]
fn fzf_opts_default_values() {
let opts = FzfOpts::default();
assert_eq!(opts.delimiter, "\t");
assert!(!opts.prompt.is_empty());
assert!(opts.extra_args.contains(&"--reverse".to_string()));
assert!(opts.extra_args.contains(&"--no-multi".to_string()));
}
#[test]
fn run_fzf_empty_rows_is_unavailable_not_cancelled() {
let out = run_fzf(&[], &FzfOpts::default());
assert_eq!(out, PickerOutcome::Unavailable);
}
#[test]
fn picker_outcome_cancelled_differs_from_unavailable() {
assert_ne!(PickerOutcome::Cancelled, PickerOutcome::Unavailable);
assert_ne!(
PickerOutcome::Selected("x".into()),
PickerOutcome::Cancelled
);
}
}