1
use std::io::{
2
  ErrorKind,
3
  Write as _,
4
};
5
use std::process::{
6
  Command,
7
  Stdio,
8
};
9

            
10
use anyhow::Context as _;
11

            
12
#[derive(Debug, Clone, PartialEq, Eq)]
13
pub(crate) struct TaskSelectorCandidate {
14
  pub(crate) name: String,
15
  pub(crate) description: String,
16
}
17

            
18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
enum Backend {
20
  Fzf,
21
  Sk,
22
}
23

            
24
impl Backend {
25
  fn command(self) -> &'static str {
26
    match self {
27
      Self::Fzf => "fzf",
28
      Self::Sk => "sk",
29
    }
30
  }
31

            
32
  fn args(self) -> [&'static str; 6] {
33
    ["--prompt", "task> ", "--delimiter", "\t", "--with-nth", "1,2"]
34
  }
35
}
36

            
37
pub(crate) struct FuzzyTaskSelector;
38

            
39
impl FuzzyTaskSelector {
40
  pub(crate) fn select(candidates: &[TaskSelectorCandidate]) -> anyhow::Result<String> {
41
    let backend = detect_backend()?;
42
    let lines = format_candidates(candidates);
43
    let mut child = Command::new(backend.command())
44
      .args(backend.args())
45
      .stdin(Stdio::piped())
46
      .stdout(Stdio::piped())
47
      .spawn()
48
      .with_context(|| format!("Failed to launch fuzzy finder `{}`", backend.command()))?;
49

            
50
    {
51
      let mut stdin = child
52
        .stdin
53
        .take()
54
        .with_context(|| format!("Failed to open stdin for fuzzy finder `{}`", backend.command()))?;
55
      for line in lines {
56
        if let Err(error) = writeln!(stdin, "{line}") {
57
          if error.kind() == ErrorKind::BrokenPipe {
58
            break;
59
          }
60
          return Err(error).with_context(|| format!("Failed to write task list to `{}`", backend.command()));
61
        }
62
      }
63
    }
64

            
65
    let output = child
66
      .wait_with_output()
67
      .with_context(|| format!("Failed to read selection from `{}`", backend.command()))?;
68

            
69
    if !output.status.success() {
70
      anyhow::bail!("No task selected");
71
    }
72

            
73
    let stdout = String::from_utf8(output.stdout)
74
      .with_context(|| format!("`{}` returned invalid UTF-8 output", backend.command()))?;
75
    parse_selection(&stdout).ok_or_else(|| anyhow::anyhow!("No task selected"))
76
  }
77
}
78

            
79
1
fn detect_backend() -> anyhow::Result<Backend> {
80
1
  if which::which("fzf").is_ok() {
81
1
    return Ok(Backend::Fzf);
82
  }
83
  if which::which("sk").is_ok() {
84
    return Ok(Backend::Sk);
85
  }
86
  anyhow::bail!("No fuzzy finder found. Install fzf or skim (sk).");
87
1
}
88

            
89
1
fn format_candidates(candidates: &[TaskSelectorCandidate]) -> Vec<String> {
90
1
  let width = candidates
91
1
    .iter()
92
2
    .map(|candidate| candidate.name.chars().count())
93
1
    .max()
94
1
    .unwrap_or(0);
95

            
96
1
  candidates
97
1
    .iter()
98
2
    .map(|candidate| {
99
2
      format!(
100
        "{name:<width$}\t{description}",
101
        name = candidate.name,
102
        description = candidate.description,
103
      )
104
2
    })
105
1
    .collect()
106
1
}
107

            
108
2
fn parse_selection(stdout: &str) -> Option<String> {
109
2
  let line = stdout.lines().find(|line| !line.trim().is_empty())?;
110
1
  let name = line.split_once('\t').map(|(name, _)| name).unwrap_or(line).trim();
111
1
  if name.is_empty() {
112
    return None;
113
1
  }
114
1
  Some(name.to_owned())
115
2
}
116

            
117
#[cfg(test)]
118
mod tests {
119
  use super::{
120
    format_candidates,
121
    parse_selection,
122
    TaskSelectorCandidate,
123
  };
124

            
125
  #[cfg(unix)]
126
  use super::{
127
    detect_backend,
128
    Backend,
129
  };
130

            
131
  #[test]
132
1
  fn parse_selection_reads_first_tsv_column() {
133
1
    assert_eq!(
134
1
      parse_selection("build    \tBuild project\n"),
135
1
      Some(String::from("build"))
136
    );
137
1
  }
138

            
139
  #[test]
140
1
  fn parse_selection_rejects_empty_output() {
141
1
    assert_eq!(parse_selection("\n"), None);
142
1
  }
143

            
144
  #[test]
145
1
  fn format_candidates_aligns_description_column() {
146
1
    let candidates = vec![
147
1
      TaskSelectorCandidate {
148
1
        name: String::from("lint"),
149
1
        description: String::from("Lint project"),
150
1
      },
151
1
      TaskSelectorCandidate {
152
1
        name: String::from("uninstall-githooks"),
153
1
        description: String::from("Uninstall git hooks"),
154
1
      },
155
    ];
156

            
157
1
    assert_eq!(
158
1
      format_candidates(&candidates),
159
1
      vec![
160
1
        String::from("lint              \tLint project"),
161
1
        String::from("uninstall-githooks\tUninstall git hooks"),
162
      ]
163
    );
164
1
  }
165

            
166
  #[cfg(unix)]
167
  #[test]
168
1
  fn detect_backend_prefers_fzf() -> anyhow::Result<()> {
169
    use std::fs;
170
    use std::os::unix::fs::PermissionsExt as _;
171

            
172
1
    let dir = std::env::temp_dir().join(format!(
173
      "mk-task-selector-{}-{}",
174
1
      std::process::id(),
175
1
      std::time::SystemTime::now()
176
1
        .duration_since(std::time::UNIX_EPOCH)?
177
1
        .as_nanos()
178
    ));
179
1
    fs::create_dir_all(&dir)?;
180
1
    let fzf = dir.join("fzf");
181
1
    let sk = dir.join("sk");
182
1
    fs::write(&fzf, "#!/bin/sh\nexit 0\n")?;
183
1
    fs::write(&sk, "#!/bin/sh\nexit 0\n")?;
184
1
    fs::set_permissions(&fzf, fs::Permissions::from_mode(0o755))?;
185
1
    fs::set_permissions(&sk, fs::Permissions::from_mode(0o755))?;
186

            
187
1
    let original_path = std::env::var_os("PATH");
188
1
    unsafe {
189
1
      std::env::set_var("PATH", &dir);
190
1
    }
191
1
    let backend = detect_backend()?;
192
1
    match original_path {
193
1
      Some(path) => unsafe { std::env::set_var("PATH", path) },
194
      None => unsafe { std::env::remove_var("PATH") },
195
    }
196
1
    let _ = fs::remove_file(&fzf);
197
1
    let _ = fs::remove_file(&sk);
198
1
    let _ = fs::remove_dir(&dir);
199

            
200
1
    assert_eq!(backend, Backend::Fzf);
201
1
    Ok(())
202
1
  }
203
}