1use core::fmt::Debug;
2use core::fmt::Display;
3use std::io::Cursor;
4
5use rootcause::report;
6use skim::Skim;
7use skim::SkimItem;
8use skim::SkimOutput;
9use skim::options::SkimOptions;
10use skim::prelude::SkimItemReader;
11use skim::prelude::SkimItemReaderOption;
12
13pub fn minimal_multi_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<Vec<T>>> {
19 if opts.is_empty() {
20 return Ok(None);
21 }
22
23 let (output, display_texts) = run_skim_prompt(&opts, select_options(true))?;
24 if output.is_abort || output.selected_items.is_empty() {
25 return Ok(None);
26 }
27
28 let mut selected_indices: Vec<usize> = output
29 .selected_items
30 .iter()
31 .filter_map(|mi| find_selected_index(&display_texts, &mi.item))
32 .collect();
33 selected_indices.sort_unstable();
34
35 let mut indexed_opts: Vec<Option<T>> = opts.into_iter().map(Some).collect();
36 let selected: Vec<T> = selected_indices
37 .into_iter()
38 .filter_map(|i| indexed_opts.get_mut(i).and_then(Option::take))
39 .collect();
40
41 if selected.is_empty() {
42 Ok(None)
43 } else {
44 Ok(Some(selected))
45 }
46}
47
48pub fn minimal_select<T: Display>(opts: Vec<T>) -> rootcause::Result<Option<T>> {
53 if opts.is_empty() {
54 return Ok(None);
55 }
56
57 let (output, display_texts) = run_skim_prompt(&opts, select_options(false))?;
58 if output.is_abort || output.selected_items.is_empty() {
59 return Ok(None);
60 }
61
62 let index = output
63 .selected_items
64 .first()
65 .and_then(|mi| find_selected_index(&display_texts, &mi.item))
66 .ok_or_else(|| report!("failed to recover selected item index"))?;
67
68 opts.into_iter()
69 .nth(index)
70 .map(Some)
71 .ok_or_else(|| report!("selected index out of bounds").attach(format!("index={index}")))
72}
73
74pub fn text_prompt(message: &str) -> rootcause::Result<Option<String>> {
79 let Some(output) = run_simple_prompt(simple_prompt_options(message, "3").build(), "")? else {
80 return Ok(None);
81 };
82 let query = output.query.trim().to_owned();
83 if query.is_empty() { Ok(None) } else { Ok(Some(query)) }
84}
85
86pub fn yes_no_select(title: &str) -> rootcause::Result<Option<bool>> {
91 let options = simple_prompt_options(title, "10%");
92
93 let Some(output) = run_simple_prompt(options.build(), "Yes\nNo")? else {
94 return Ok(None);
95 };
96 if output.selected_items.is_empty() {
97 return Ok(None);
98 }
99
100 let selected_text = output.selected_items.first().map(|mi| mi.item.output().into_owned());
101
102 Ok(Some(selected_text.as_deref() == Some("Yes")))
103}
104
105pub fn require_single<'a, T>(selected: &'a [T], item_name_plural: &str) -> rootcause::Result<&'a T> {
111 let [item] = selected else {
112 return Err(report!("expected exactly one selection")
113 .attach(format!("item_name_plural={item_name_plural}"))
114 .attach(format!("selected_count={}", selected.len())));
115 };
116 Ok(item)
117}
118
119pub fn get_item_from_cli_args_or_select<'a, CAS, O, OBA, OF>(
129 cli_args: &'a [String],
130 mut cli_arg_selector: CAS,
131 items: Vec<O>,
132 item_find_by_arg: OBA,
133) -> rootcause::Result<Option<O>>
134where
135 O: Clone + Debug + Display,
136 CAS: FnMut(&(usize, &String)) -> bool,
137 OBA: Fn(&'a str) -> OF,
138 OF: FnMut(&O) -> bool + 'a,
139{
140 if let Some((_, cli_arg)) = cli_args.iter().enumerate().find(|x| cli_arg_selector(x)) {
141 let mut item_find = item_find_by_arg(cli_arg);
142 return Ok(Some(items.iter().find(|x| item_find(*x)).cloned().ok_or_else(
143 || report!("missing item matching CLI arg").attach(format!("cli_arg={cli_arg} items={items:#?}")),
144 )?));
145 }
146 minimal_select(items)
147}
148
149fn run_simple_prompt(options: SkimOptions, input: &str) -> rootcause::Result<Option<SkimOutput>> {
151 let items = SkimItemReader::default().of_bufread(Cursor::new(input.to_owned()));
152 let output =
153 Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
154 if output.is_abort {
155 return Ok(None);
156 }
157 Ok(Some(output))
158}
159
160fn run_skim_prompt<T: Display>(opts: &[T], options: SkimOptions) -> rootcause::Result<(SkimOutput, Vec<String>)> {
163 let display_texts: Vec<String> = opts.iter().map(ToString::to_string).collect();
164 let input = display_texts.join("\n");
165 let reader_opts = SkimItemReaderOption::from_options(&options);
166 let items = SkimItemReader::new(reader_opts).of_bufread(Cursor::new(input));
167 let output =
168 Skim::run_with(options, Some(items)).map_err(|e| report!("skim failed to run").attach(e.to_string()))?;
169 Ok((output, display_texts))
170}
171
172fn find_selected_index(display_texts: &[String], item: &std::sync::Arc<dyn SkimItem>) -> Option<usize> {
175 let output_text = item.output();
176 display_texts.iter().position(|t| *t == *output_text)
177}
178
179fn base_skim_options() -> SkimOptions {
182 let mut opts = SkimOptions::default();
183 opts.reverse = true;
184 opts.no_info = true;
185 opts.exact = true;
186 opts.no_sort = true;
187 opts.cycle = true;
188 opts.bind = vec!["enter:accept".into(), "esc:abort".into(), "ctrl-c:abort".into()];
189 opts
190}
191
192fn simple_prompt_options(prompt: &str, height: &str) -> SkimOptions {
194 let mut opts = base_skim_options();
195 opts.height = height.into();
196 opts.prompt = format!("{prompt} ");
197 opts
198}
199
200fn select_options(multi: bool) -> SkimOptions {
202 let mut opts = base_skim_options();
203 opts.multi = multi;
204 opts.ansi = true;
205 opts.height = "40%".into();
206 if multi {
207 opts.bind.extend(["ctrl-e:toggle".into(), "ctrl-a:toggle-all".into()]);
208 }
209 opts.build()
210}
211
212#[cfg(test)]
213mod tests {
214 #[test]
215 fn test_require_single_returns_only_item() {
216 let selected = vec![1];
217 assert2::assert!(let Ok(item) = super::require_single(&selected, "items"));
218 assert_eq!(*item, 1);
219 }
220
221 #[test]
222 fn test_require_single_errors_for_multiple_items() {
223 let selected = vec![1, 2];
224 assert2::assert!(let Err(err) = super::require_single(&selected, "items"));
225 assert!(err.to_string().contains("expected exactly one selection"));
226 }
227}