1use std::borrow::Cow;
2use std::fmt::Debug;
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::io::Cursor;
6use std::rc::Rc;
7use std::sync::Arc;
8
9use ratatui::text::Line;
10use rootcause::report;
11use skim::DisplayContext;
12use skim::MatchEngine;
13use skim::MatchEngineFactory;
14use skim::MatchRange;
15use skim::MatchResult;
16use skim::Skim;
17use skim::SkimItem;
18use skim::SkimItemReceiver;
19use skim::SkimOutput;
20use skim::options::SkimOptions;
21use skim::prelude::SkimItemReader;
22use skim::prelude::SkimItemReaderOption;
23
24pub fn minimal_multi_select<T, D, S>(
32 opts: Vec<T>,
33 mut display_text: D,
34 mut search_text: S,
35) -> rootcause::Result<Option<Vec<T>>>
36where
37 D: FnMut(&T) -> String,
38 S: FnMut(&T) -> String,
39{
40 if opts.is_empty() {
41 return Ok(None);
42 }
43
44 let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
45 let display_texts: Vec<String> = opts.iter().map(|opt| normalize(&display_text(opt))).collect();
46 let display_items = build_ansi_display_items(&display_texts)?;
47 let items: Vec<Arc<dyn SkimItem>> = opts
48 .iter()
49 .enumerate()
50 .map(|(index, opt)| {
51 let display_item = Arc::clone(display_items.get(index)?);
52 let visible_match_text = display_item.text().into_owned();
53 let hidden_search = normalize(&search_text(opt));
54 let search_corpus = if hidden_search.is_empty() || hidden_search == visible_match_text {
55 visible_match_text.clone()
56 } else {
57 format!("{visible_match_text} {hidden_search}")
58 };
59
60 Some(Arc::new(IndexedSkimItem {
61 output: index.to_string(),
62 display_item,
63 visible_text: visible_match_text,
64 search_corpus,
65 }) as Arc<dyn SkimItem>)
66 })
67 .collect::<Option<Vec<_>>>()
68 .ok_or_else(|| report!("missing ANSI display item while building skim rows"))?;
69
70 let (tx_items, rx_items) = skim::prelude::unbounded();
71 tx_items
72 .send(items)
73 .map_err(|e| report!("failed to queue skim items").attach(e.to_string()))?;
74 drop(tx_items);
75
76 let options = select_options(true);
77 let output = run_skim_with_matcher(options, rx_items)?;
78
79 if output.is_abort || output.selected_items.is_empty() {
80 return Ok(None);
81 }
82
83 let mut selected_indices: Vec<usize> = output
84 .selected_items
85 .iter()
86 .filter_map(|mi| mi.item.output().parse().ok())
87 .collect();
88 selected_indices.sort_unstable();
89 selected_indices.dedup();
90
91 let mut indexed_opts: Vec<Option<T>> = opts.into_iter().map(Some).collect();
92 let selected: Vec<T> = selected_indices
93 .into_iter()
94 .filter_map(|i| indexed_opts.get_mut(i).and_then(Option::take))
95 .collect();
96
97 if selected.is_empty() {
98 Ok(None)
99 } else {
100 Ok(Some(selected))
101 }
102}
103
104pub fn minimal_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<T>> {
109 if opts.is_empty() {
110 return Ok(None);
111 }
112
113 let (output, display_texts) = run_skim_prompt(&opts, select_options(false))?;
114 if output.is_abort || output.selected_items.is_empty() {
115 return Ok(None);
116 }
117
118 let index = output
119 .selected_items
120 .first()
121 .and_then(|mi| {
122 let output_text = mi.item.output();
123 display_texts.iter().position(|t| *t == *output_text)
124 })
125 .ok_or_else(|| report!("failed to recover selected item index"))?;
126
127 opts.into_iter()
128 .nth(index)
129 .map(Some)
130 .ok_or_else(|| report!("selected index out of bounds").attach(format!("index={index}")))
131}
132
133pub fn text_prompt(message: &str) -> rootcause::Result<Option<String>> {
138 let Some(output) = run_simple_prompt(simple_prompt_options(message, "3").build(), "")? else {
139 return Ok(None);
140 };
141 let query = output.query.trim().to_owned();
142 if query.is_empty() { Ok(None) } else { Ok(Some(query)) }
143}
144
145pub fn yes_no_select(title: &str) -> rootcause::Result<Option<bool>> {
150 let options = simple_prompt_options(title, "10%");
151
152 let Some(output) = run_simple_prompt(options.build(), "Yes\nNo")? else {
153 return Ok(None);
154 };
155 if output.selected_items.is_empty() {
156 return Ok(None);
157 }
158
159 let selected_text = output.selected_items.first().map(|mi| mi.item.output().into_owned());
160
161 Ok(Some(selected_text.as_deref() == Some("Yes")))
162}
163
164pub fn require_single<'a, T>(selected: &'a [T], item_name_plural: &str) -> rootcause::Result<&'a T> {
170 let [item] = selected else {
171 return Err(report!("expected exactly one selection")
172 .attach(format!("item_name_plural={item_name_plural}"))
173 .attach(format!("selected_count={}", selected.len())));
174 };
175 Ok(item)
176}
177
178pub fn get_item_from_cli_args_or_select<'a, CAS, O, OBA, OF>(
188 cli_args: &'a [String],
189 mut cli_arg_selector: CAS,
190 items: Vec<O>,
191 item_find_by_arg: OBA,
192) -> rootcause::Result<Option<O>>
193where
194 O: Clone + Debug + Display,
195 CAS: FnMut(&(usize, &String)) -> bool,
196 OBA: Fn(&'a str) -> OF,
197 OF: FnMut(&O) -> bool + 'a,
198{
199 let Some((_, cli_arg)) = cli_args.iter().enumerate().find(|x| cli_arg_selector(x)) else {
200 return minimal_select(items);
201 };
202 let mut item_find = item_find_by_arg(cli_arg);
203 Ok(Some(items.iter().find(|x| item_find(*x)).cloned().ok_or_else(
204 || report!("missing item matching CLI arg").attach(format!("cli_arg={cli_arg} items={items:#?}")),
205 )?))
206}
207
208#[derive(Debug)]
209struct IndexedSkimItem {
210 output: String,
211 display_item: Arc<dyn SkimItem>,
212 visible_text: String,
213 search_corpus: String,
214}
215
216impl SkimItem for IndexedSkimItem {
217 fn text(&self) -> Cow<'_, str> {
218 Cow::Borrowed(&self.visible_text)
219 }
220
221 fn display(&self, context: DisplayContext) -> Line<'_> {
222 self.display_item.display(context)
223 }
224
225 fn output(&self) -> Cow<'_, str> {
226 Cow::Borrowed(&self.output)
227 }
228}
229
230struct SearchCorpusEngineFactory {
231 inner: Rc<dyn MatchEngineFactory>,
232}
233
234impl SearchCorpusEngineFactory {
235 fn new(inner: Rc<dyn MatchEngineFactory>) -> Self {
236 Self { inner }
237 }
238}
239
240impl MatchEngineFactory for SearchCorpusEngineFactory {
241 fn create_engine_with_case(&self, query: &str, case: skim::CaseMatching) -> Box<dyn MatchEngine> {
242 Box::new(SearchCorpusEngine {
243 inner: self.inner.create_engine_with_case(query, case),
244 })
245 }
246}
247
248struct SearchCorpusEngine {
249 inner: Box<dyn MatchEngine>,
250}
251
252impl Display for SearchCorpusEngine {
253 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
254 write!(f, "{}", self.inner)
255 }
256}
257
258impl MatchEngine for SearchCorpusEngine {
259 fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
260 let Some(item) = item.as_any().downcast_ref::<IndexedSkimItem>() else {
261 return self.inner.match_item(item);
262 };
263
264 let mut result = self.inner.match_item(&item.search_corpus)?;
265 result.matched_range = clip_match_range(result.matched_range, item);
266 Some(result)
267 }
268}
269
270fn clip_match_range(match_range: MatchRange, item: &IndexedSkimItem) -> MatchRange {
271 let visible_char_len = item.visible_text.chars().count();
272 let visible_byte_len = item.visible_text.len();
273
274 match match_range {
275 MatchRange::Chars(indices) => {
276 MatchRange::Chars(indices.into_iter().filter(|index| *index < visible_char_len).collect())
277 }
278 MatchRange::CharRange(start, end) => {
279 if start >= visible_char_len {
280 MatchRange::Chars(Vec::new())
281 } else {
282 MatchRange::CharRange(start, end.min(visible_char_len))
283 }
284 }
285 MatchRange::ByteRange(start, end) => {
286 if start >= visible_byte_len {
287 MatchRange::Chars(Vec::new())
288 } else {
289 MatchRange::ByteRange(start, end.min(visible_byte_len))
290 }
291 }
292 }
293}
294
295fn run_skim_with_matcher(options: SkimOptions, source: SkimItemReceiver) -> rootcause::Result<SkimOutput> {
296 let (engine_factory, rank_builder) = skim::matcher::Matcher::create_engine_factory_with_builder(&options);
297 let matcher = skim::matcher::Matcher::builder(Rc::new(SearchCorpusEngineFactory::new(engine_factory)))
298 .case(options.case)
299 .rank_builder(rank_builder)
300 .build();
301
302 let mut skim = skim::Skim::init(options, Some(source))
303 .map_err(|e| report!("skim failed to initialize").attach(e.to_string()))?;
304 skim.app_mut().matcher = matcher;
305 skim.start();
306
307 if skim.should_enter() {
308 skim.init_tui()
309 .map_err(|e| report!("skim failed to initialize TUI").attach(e.to_string()))?;
310
311 let task = async {
312 skim.enter().await.map_err(|e| e.to_string())?;
313 skim.run().await.map_err(|e| e.to_string())?;
314 Ok::<(), String>(())
315 };
316
317 if let Ok(handle) = tokio::runtime::Handle::try_current() {
318 tokio::task::block_in_place(|| handle.block_on(task))
319 .map_err(|e| report!("skim failed to run").attach(e))?;
320 } else {
321 tokio::runtime::Runtime::new()
322 .map_err(|e| report!("failed to create tokio runtime").attach(e.to_string()))?
323 .block_on(task)
324 .map_err(|e| report!("skim failed to run").attach(e))?;
325 }
326 }
327
328 Ok(skim.output())
329}
330
331fn build_ansi_display_items(display_texts: &[String]) -> rootcause::Result<Vec<Arc<dyn SkimItem>>> {
332 let input = display_texts.join("\n");
333
334 let reader_opts = SkimItemReaderOption::default().ansi(true).build();
335 let receiver = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
336 let mut items = Vec::with_capacity(display_texts.len());
337 while let Ok(batch) = receiver.recv() {
338 items.extend(batch);
339 }
340
341 if items.len() != display_texts.len() {
342 return Err(report!("failed to build ANSI display items")
343 .attach(format!("expected={}", display_texts.len()))
344 .attach(format!("actual={}", items.len())));
345 }
346 Ok(items)
347}
348
349fn run_simple_prompt(options: SkimOptions, input: &str) -> rootcause::Result<Option<SkimOutput>> {
351 let items = SkimItemReader::default().of_bufread(Cursor::new(input.to_owned()));
352 let output =
353 Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
354 if output.is_abort {
355 return Ok(None);
356 }
357 Ok(Some(output))
358}
359
360fn run_skim_prompt<T: Display>(opts: &[T], options: SkimOptions) -> rootcause::Result<(SkimOutput, Vec<String>)> {
363 let display_texts: Vec<String> = opts.iter().map(ToString::to_string).collect();
364 let input = display_texts.join("\n");
365 let reader_opts = SkimItemReaderOption::from_options(&options);
366 let items = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
367 let output =
368 Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
369 Ok((output, display_texts))
370}
371
372fn base_skim_options() -> SkimOptions {
375 let mut opts = SkimOptions::default();
376 opts.reverse = true;
377 opts.no_info = true;
378 opts.exact = true;
379 opts.no_sort = true;
380 opts.cycle = true;
381 opts.bind = vec!["enter:accept".into(), "esc:abort".into(), "ctrl-c:abort".into()];
382 opts
383}
384
385fn simple_prompt_options(prompt: &str, height: &str) -> SkimOptions {
387 let mut opts = base_skim_options();
388 opts.height = height.into();
389 opts.prompt = format!("{prompt} ");
390 opts
391}
392
393fn select_options(multi: bool) -> SkimOptions {
395 let mut opts = base_skim_options();
396 opts.multi = multi;
397 opts.ansi = true;
398 opts.height = "40%".into();
399 if multi {
400 opts.bind.extend(["ctrl-e:toggle".into(), "ctrl-a:toggle-all".into()]);
401 }
402 opts.build()
403}
404
405#[cfg(test)]
406mod tests {
407 use std::sync::Arc;
408
409 use skim::DisplayContext;
410 use skim::MatchRange;
411 use skim::SkimItem;
412
413 #[test]
414 fn test_require_single_returns_only_item() {
415 let selected = vec![1];
416 assert2::assert!(let Ok(item) = super::require_single(&selected, "items"));
417 pretty_assertions::assert_eq!(*item, 1);
418 }
419
420 #[test]
421 fn test_require_single_errors_for_multiple_items() {
422 let selected = vec![1, 2];
423 assert2::assert!(let Err(err) = super::require_single(&selected, "items"));
424 assert!(err.to_string().contains("expected exactly one selection"));
425 }
426
427 #[test]
428 fn test_minimal_multi_select_line_serialization_sanitizes_fields() {
429 let normalize = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
430 let display = normalize("\u{1b}[31mvisible\tvalue\nnext\u{1b}[0m");
431 let hidden_search = normalize("hidden\rvalue");
432 assert2::assert!(let Ok(mut display_items) = super::build_ansi_display_items(std::slice::from_ref(&display)));
433 let display_item = display_items.swap_remove(0);
434 let match_text = format!("{} {hidden_search}", display_item.text());
435
436 let item = super::IndexedSkimItem {
437 output: "3".to_owned(),
438 display_item,
439 visible_text: "visible value next".to_owned(),
440 search_corpus: match_text,
441 };
442
443 pretty_assertions::assert_eq!(item.output(), "3");
444 pretty_assertions::assert_eq!(item.text(), "visible value next");
445 pretty_assertions::assert_eq!(
446 item.display(DisplayContext::default())
447 .spans
448 .first()
449 .map(|span| span.content.as_ref()),
450 Some("visible value next")
451 );
452 }
453
454 #[test]
455 fn test_clip_match_range_char_indices_hides_hidden_only_match() {
456 let item = super::IndexedSkimItem {
457 output: "3".to_owned(),
458 display_item: Arc::new("visible value next".to_owned()),
459 visible_text: "visible value next".to_owned(),
460 search_corpus: "visible value next hidden value".to_owned(),
461 };
462
463 assert!(matches!(
464 super::clip_match_range(MatchRange::Chars(vec![20, 21]), &item),
465 MatchRange::Chars(indices) if indices.is_empty()
466 ));
467 }
468}