Skip to main content

lore/tui/
app.rs

1//! Picker state and key handling, kept free of any terminal so it can be tested
2//! directly.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::path::PathBuf;
6
7use anyhow::Result;
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10use crate::model::{CommandBody, Entry, Layer, ParamSpec, ShellFamily};
11use crate::params;
12use crate::search::{self, Candidate};
13use crate::store::definitions::{self, NewEntry};
14use crate::store::stats::{Score, Stats};
15use crate::tui::form::{Field, Form};
16
17/// Shown when saving is asked to go on without a command.
18const NO_COMMAND: &str = "Type a command, or press up for the ones you ran";
19
20/// Commands from the shell's history that saving can walk back through.
21///
22/// Deep enough to reach past a run of throwaway commands, as a shell's own up
23/// arrow would.
24const HISTORY_LIMIT: usize = 50;
25
26/// What the picker hands back to the shell.
27#[derive(Debug, PartialEq, Eq)]
28pub enum Outcome {
29    /// Command to place in the prompt. Running it stays the user's decision.
30    Insert {
31        command: String,
32        /// Where to leave the cursor, in characters from the start of the
33        /// command. `None` puts it at the end, which is where a finished
34        /// command wants it.
35        cursor: Option<usize>,
36    },
37    Cancelled,
38}
39
40impl Outcome {
41    #[cfg(test)]
42    fn insert(command: &str) -> Self {
43        Self::Insert {
44            command: command.to_string(),
45            cursor: None,
46        }
47    }
48}
49
50pub enum Mode {
51    Browse,
52    Params {
53        entry_id: String,
54        template: String,
55        form: Form,
56    },
57    Save(Save),
58    /// Rewriting an entry in place. Everything the form does not show is
59    /// carried through untouched, so editing a description cannot lose a
60    /// per shell variant or a placeholder's documentation.
61    Edit {
62        id: String,
63        cmd: CommandBody,
64        params: BTreeMap<String, ParamSpec>,
65        danger: bool,
66        /// A builtin is rewritten as a user entry that shadows it rather than
67        /// changed where it lives.
68        shadowing: bool,
69        form: Form,
70    },
71}
72
73/// Saving a command, asked the way a shell would ask it: the command on one
74/// line, then what it is for on the next. There is no form to fill in, and
75/// tags are either written into the answer as `#tag` or taken from the
76/// command's own words.
77pub struct Save {
78    pub step: SaveStep,
79    pub command: String,
80    pub purpose: String,
81    /// The history entry the command line is showing while up and down walk
82    /// through it. `None` once the line is past the newest, as in a shell.
83    pub recalled: Option<usize>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SaveStep {
88    Command,
89    Purpose,
90}
91
92impl Save {
93    fn line(&mut self) -> &mut String {
94        match self.step {
95            SaveStep::Command => &mut self.command,
96            SaveStep::Purpose => &mut self.purpose,
97        }
98    }
99}
100
101pub struct App {
102    entries: Vec<Entry>,
103    family: ShellFamily,
104    /// Entries that have a command for this shell, in load order.
105    pickable: Vec<usize>,
106    /// Ranked entry indices, best first.
107    order: Vec<usize>,
108    selected: usize,
109    query: String,
110    mode: Mode,
111    status: Option<String>,
112    stats: Stats,
113    scores: HashMap<String, Score>,
114    library: PathBuf,
115    /// What the shell last ran, newest first, offered when saving.
116    history: Vec<String>,
117    /// Entry the next ctrl+x will actually remove.
118    armed_to_remove: Option<String>,
119    /// Set once the library file has been written, so the caller knows there
120    /// is something to sync.
121    changed: bool,
122    now: i64,
123}
124
125impl App {
126    pub fn new(
127        entries: Vec<Entry>,
128        family: ShellFamily,
129        stats: Stats,
130        library: PathBuf,
131        history: Vec<String>,
132        now: i64,
133    ) -> Result<Self> {
134        let scores = stats.scores(now)?;
135        let mut app = Self {
136            entries,
137            family,
138            pickable: Vec::new(),
139            order: Vec::new(),
140            selected: 0,
141            query: String::new(),
142            mode: Mode::Browse,
143            status: None,
144            stats,
145            scores,
146            library,
147            history: prepare_history(history),
148            armed_to_remove: None,
149            changed: false,
150            now,
151        };
152        app.reindex();
153        Ok(app)
154    }
155
156    pub fn query(&self) -> &str {
157        &self.query
158    }
159
160    pub fn mode(&self) -> &Mode {
161        &self.mode
162    }
163
164    pub fn status(&self) -> Option<&str> {
165        self.status.as_deref()
166    }
167
168    pub fn selected(&self) -> usize {
169        self.selected
170    }
171
172    pub fn matches(&self) -> usize {
173        self.order.len()
174    }
175
176    /// The entries currently on offer, best first.
177    pub fn rows(&self) -> impl Iterator<Item = Row<'_>> {
178        self.order.iter().map(move |&index| {
179            let entry = &self.entries[index];
180            Row {
181                entry,
182                cmd: entry.cmd_for(self.family).expect("filtered on this"),
183                pinned: self.scores.get(&entry.id).is_some_and(|s| s.pinned),
184            }
185        })
186    }
187
188    pub fn selected_row(&self) -> Option<Row<'_>> {
189        self.rows().nth(self.selected)
190    }
191
192    /// Highlights the query inside a haystack for the rows on screen.
193    pub fn highlight(&self, haystack: &str) -> Vec<u32> {
194        search::highlight(haystack, &self.query)
195    }
196
197    pub fn on_key(&mut self, key: KeyEvent) -> Result<Option<Outcome>> {
198        self.status = None;
199        // Any other keystroke stands the confirmation down.
200        let armed = self.armed_to_remove.take();
201
202        let control = key.modifiers.contains(KeyModifiers::CONTROL);
203        if control && matches!(key.code, KeyCode::Char('c')) {
204            return Ok(Some(Outcome::Cancelled));
205        }
206
207        match &mut self.mode {
208            Mode::Browse => self.browse_key(key, control, armed),
209            Mode::Params { .. } => self.params_key(key, control),
210            Mode::Save { .. } => self.save_key(key, control),
211            Mode::Edit { .. } => self.edit_key(key, control),
212        }
213    }
214
215    fn browse_key(
216        &mut self,
217        key: KeyEvent,
218        control: bool,
219        armed: Option<String>,
220    ) -> Result<Option<Outcome>> {
221        match key.code {
222            KeyCode::Esc => return Ok(Some(Outcome::Cancelled)),
223            KeyCode::Char('g') if control => return Ok(Some(Outcome::Cancelled)),
224            KeyCode::Enter => return self.choose(),
225
226            KeyCode::Up => self.move_by(-1),
227            KeyCode::Down => self.move_by(1),
228            KeyCode::Char('k') if control => self.move_by(-1),
229            KeyCode::Char('j') if control => self.move_by(1),
230            KeyCode::PageUp => self.move_by(-10),
231            KeyCode::PageDown => self.move_by(10),
232
233            KeyCode::Char('p') if control => self.toggle_pin()?,
234            KeyCode::Char('s') if control => self.begin_save(),
235            KeyCode::Char('e') if control => self.begin_edit(),
236            KeyCode::Char('x') if control => self.remove(armed)?,
237
238            KeyCode::Char('u') if control => {
239                self.query.clear();
240                self.reindex();
241            }
242            KeyCode::Backspace => {
243                self.query.pop();
244                self.reindex();
245            }
246            KeyCode::Char(character) if !control => {
247                self.query.push(character);
248                self.reindex();
249            }
250            _ => {}
251        }
252
253        Ok(None)
254    }
255
256    fn params_key(&mut self, key: KeyEvent, control: bool) -> Result<Option<Outcome>> {
257        let Mode::Params { form, .. } = &mut self.mode else {
258            return Ok(None);
259        };
260
261        match key.code {
262            KeyCode::Esc => self.mode = Mode::Browse,
263            KeyCode::Enter | KeyCode::Tab | KeyCode::Down => {
264                if form.advance() {
265                    return self.finish_params();
266                }
267            }
268            KeyCode::Up | KeyCode::BackTab => form.retreat(),
269            KeyCode::Char('u') if control => form.clear(),
270            KeyCode::Backspace => form.backspace(),
271            KeyCode::Char(character) if !control => form.insert(character),
272            _ => {}
273        }
274
275        Ok(None)
276    }
277
278    fn save_key(&mut self, key: KeyEvent, control: bool) -> Result<Option<Outcome>> {
279        let Mode::Save(save) = &mut self.mode else {
280            return Ok(None);
281        };
282
283        match (save.step, key.code) {
284            (_, KeyCode::Esc) => self.mode = Mode::Browse,
285            // Submits from wherever the cursor is. The checks in finish_save
286            // still apply, and send the cursor to whatever is missing.
287            (_, KeyCode::Char('s')) if control => self.finish_save()?,
288
289            (SaveStep::Command, KeyCode::Up) => self.recall(1),
290            (SaveStep::Command, KeyCode::Down) => self.recall(-1),
291            (SaveStep::Command, KeyCode::Enter | KeyCode::Tab) => {
292                if save.command.trim().is_empty() {
293                    self.status = Some(NO_COMMAND.to_string());
294                } else {
295                    save.step = SaveStep::Purpose;
296                }
297            }
298
299            (SaveStep::Purpose, KeyCode::Enter) => self.finish_save()?,
300            (SaveStep::Purpose, KeyCode::Up | KeyCode::BackTab) => {
301                save.step = SaveStep::Command;
302            }
303
304            (_, KeyCode::Char('u')) if control => save.line().clear(),
305            (_, KeyCode::Backspace) => {
306                save.line().pop();
307            }
308            (_, KeyCode::Char(character)) if !control => save.line().push(character),
309            _ => {}
310        }
311
312        Ok(None)
313    }
314
315    /// Walks the command line through the shell's history, `older` steps back.
316    ///
317    /// Past the newest entry the line is empty, the way a shell's own prompt is
318    /// when down is pressed at the bottom of its history.
319    fn recall(&mut self, older: isize) {
320        let Mode::Save(save) = &mut self.mode else {
321            return;
322        };
323
324        let next = match save.recalled {
325            Some(index) => index as isize + older,
326            None if older > 0 => 0,
327            None => return,
328        };
329
330        if next < 0 {
331            save.recalled = None;
332            save.command.clear();
333        } else if let Some(command) = self.history.get(next as usize) {
334            save.recalled = Some(next as usize);
335            save.command = command.clone();
336        }
337    }
338
339    fn edit_key(&mut self, key: KeyEvent, control: bool) -> Result<Option<Outcome>> {
340        let Mode::Edit { form, .. } = &mut self.mode else {
341            return Ok(None);
342        };
343
344        match key.code {
345            KeyCode::Esc => self.mode = Mode::Browse,
346            KeyCode::Enter | KeyCode::Tab | KeyCode::Down => {
347                if form.advance() {
348                    self.finish_edit()?;
349                }
350            }
351            KeyCode::Up | KeyCode::BackTab => form.retreat(),
352            KeyCode::Char('s') if control => self.finish_edit()?,
353            KeyCode::Char('u') if control => form.clear(),
354            KeyCode::Backspace => form.backspace(),
355            KeyCode::Char(character) if !control => form.insert(character),
356            _ => {}
357        }
358
359        Ok(None)
360    }
361
362    /// Enter on a row: prompt for placeholders, or hand the command back.
363    fn choose(&mut self) -> Result<Option<Outcome>> {
364        let Some(row) = self.selected_row() else {
365            return Ok(None);
366        };
367        let entry_id = row.entry.id.clone();
368        let template = row.cmd.to_string();
369
370        let names = params::names(&template);
371        if names.is_empty() {
372            self.stats.record_use(&entry_id, self.now)?;
373            return Ok(Some(Outcome::Insert {
374                command: template,
375                cursor: None,
376            }));
377        }
378
379        // One placeholder is not worth a screen. The command goes to the prompt
380        // with the placeholder cut out and the cursor in the gap, so the value
381        // is typed against the shell's own completion rather than into a form
382        // that knows nothing about paths, branches or container names.
383        if let [placeholder] = params::parse(&template).as_slice() {
384            let span = placeholder.span.clone();
385            let cursor = template[..span.start].chars().count();
386            let mut command = template;
387            command.replace_range(span, "");
388
389            self.stats.record_use(&entry_id, self.now)?;
390            return Ok(Some(Outcome::Insert {
391                command,
392                cursor: Some(cursor),
393            }));
394        }
395
396        let remembered = self.stats.last_params(&entry_id)?;
397        let defaults: BTreeMap<String, String> = params::parse(&template)
398            .into_iter()
399            .filter_map(|p| p.default.map(|value| (p.name, value)))
400            .collect();
401
402        let fields = names
403            .iter()
404            .map(|name| {
405                let value = remembered
406                    .get(name)
407                    .or_else(|| defaults.get(name))
408                    .cloned()
409                    .unwrap_or_default();
410                let hint = self
411                    .entries
412                    .iter()
413                    .find(|e| e.id == entry_id)
414                    .and_then(|e| e.params.get(name))
415                    .and_then(|spec| spec.desc.clone());
416                Field::new(name, value).with_hint(hint)
417            })
418            .collect();
419
420        self.mode = Mode::Params {
421            entry_id,
422            template,
423            form: Form::new("Fill in the placeholders", fields),
424        };
425        Ok(None)
426    }
427
428    fn finish_params(&mut self) -> Result<Option<Outcome>> {
429        let Mode::Params {
430            entry_id,
431            template,
432            form,
433        } = &self.mode
434        else {
435            return Ok(None);
436        };
437
438        let mut values = BTreeMap::new();
439        for field in &form.fields {
440            values.insert(field.label.clone(), field.value.trim().to_string());
441        }
442
443        let command = params::render(template, &values);
444        let entry_id = entry_id.clone();
445
446        for (name, value) in &values {
447            self.stats.remember_param(&entry_id, name, value)?;
448        }
449        self.stats.record_use(&entry_id, self.now)?;
450
451        Ok(Some(Outcome::Insert {
452            command,
453            cursor: None,
454        }))
455    }
456
457    /// Starts saving on the newest thing the shell has.
458    ///
459    /// The shell puts whatever was on the prompt line ahead of its history, so
460    /// a command typed but not yet run is what gets offered first, and the
461    /// last one run when the line was empty.
462    fn begin_save(&mut self) {
463        self.mode = Mode::Save(Save {
464            step: SaveStep::Command,
465            command: self.history.first().cloned().unwrap_or_default(),
466            purpose: String::new(),
467            recalled: (!self.history.is_empty()).then_some(0),
468        });
469    }
470
471    fn finish_save(&mut self) -> Result<()> {
472        let Mode::Save(save) = &mut self.mode else {
473            return Ok(());
474        };
475
476        let command = save.command.trim().to_string();
477        let (description, given) = definitions::split_purpose(&save.purpose);
478
479        if command.is_empty() {
480            save.step = SaveStep::Command;
481            self.status = Some(NO_COMMAND.to_string());
482            return Ok(());
483        }
484        // Without a description the entry is only findable by its own text,
485        // which defeats the point of saving it in the first place.
486        if description.is_empty() {
487            save.step = SaveStep::Purpose;
488            self.status = Some("Say what it is for, so you can find it later".to_string());
489            return Ok(());
490        }
491
492        let taken: BTreeSet<String> = self.entries.iter().map(|e| e.id.clone()).collect();
493        let entry = NewEntry {
494            id: definitions::suggest_id(&command, &taken),
495            tags: definitions::merge_tags(given, &command),
496            cmd: CommandBody::Shared(command),
497            desc: description,
498            params: BTreeMap::new(),
499            danger: false,
500        };
501        let id = entry.id.clone();
502
503        definitions::append(&self.library, &entry)?;
504        self.stats.record_new(&id, self.now)?;
505
506        self.entries = definitions::load(Some(&self.library))?;
507        self.scores = self.stats.scores(self.now)?;
508        self.mode = Mode::Browse;
509        self.query.clear();
510        self.reindex();
511        self.select_id(&id);
512        self.status = Some(format!("Saved as {id}"));
513        self.changed = true;
514
515        Ok(())
516    }
517
518    /// Whether the library already holds exactly this command for this shell.
519    pub fn is_saved(&self, command: &str) -> bool {
520        let command = command.trim();
521        self.entries
522            .iter()
523            .any(|entry| entry.cmd_for(self.family) == Some(command))
524    }
525
526    /// Tags the entry being saved would get, for showing before it is saved.
527    pub fn save_tags(&self) -> Vec<String> {
528        let Mode::Save(save) = &self.mode else {
529            return Vec::new();
530        };
531        let (_, given) = definitions::split_purpose(&save.purpose);
532        definitions::merge_tags(given, save.command.trim())
533    }
534
535    /// Opens the picker on a search already typed.
536    pub fn search(&mut self, query: String) {
537        self.query = query;
538        self.reindex();
539    }
540
541    /// Shows `message` where the hints go until the first key is pressed.
542    pub fn notice(&mut self, message: String) {
543        self.status = Some(message);
544    }
545
546    /// Whether anything in the library was written while the picker was open.
547    pub fn changed(&self) -> bool {
548        self.changed
549    }
550
551    /// Opens the edit screen on the selected entry.
552    ///
553    /// The id is not offered: changing it would orphan everything the usage
554    /// statistics have learned about the entry, and the entry can be removed
555    /// and saved again if it really needs a different one.
556    fn begin_edit(&mut self) {
557        let Some(row) = self.selected_row() else {
558            return;
559        };
560        let entry = row.entry;
561
562        let form = Form::new(
563            format!("Edit {}", entry.id),
564            vec![
565                Field::new("command", row.cmd.to_string()),
566                Field::new("description", entry.desc.clone()),
567                Field::new("tags", entry.tags.join(", "))
568                    .with_hint(Some("comma separated".to_string())),
569            ],
570        );
571
572        self.mode = Mode::Edit {
573            id: entry.id.clone(),
574            cmd: entry.cmd.clone(),
575            params: entry.params.clone(),
576            danger: entry.danger,
577            shadowing: entry.layer != Layer::User,
578            form,
579        };
580    }
581
582    fn finish_edit(&mut self) -> Result<()> {
583        let Mode::Edit {
584            id,
585            cmd,
586            params,
587            danger,
588            shadowing,
589            form,
590        } = &self.mode
591        else {
592            return Ok(());
593        };
594
595        let command = form.value(0).to_string();
596        let description = form.value(1).to_string();
597
598        if command.is_empty() {
599            self.status = Some("A command is required".to_string());
600            return Ok(());
601        }
602        if description.is_empty() {
603            self.status = Some("A description is required to find this later".to_string());
604            if let Mode::Edit { form, .. } = &mut self.mode {
605                form.focused = 1;
606            }
607            return Ok(());
608        }
609
610        // Only the variant for the shell being used is replaced. The others
611        // were never on screen and are none of this edit's business.
612        let cmd = match cmd {
613            CommandBody::Shared(_) => CommandBody::Shared(command),
614            CommandBody::PerShell(variants) => {
615                let mut variants = variants.clone();
616                variants.insert(self.family, command);
617                CommandBody::PerShell(variants)
618            }
619        };
620
621        let entry = NewEntry {
622            id: id.clone(),
623            cmd,
624            desc: description,
625            tags: definitions::parse_tags(form.value(2)),
626            params: params.clone(),
627            danger: *danger,
628        };
629        let id = entry.id.clone();
630        let shadowing = *shadowing;
631
632        definitions::upsert(&self.library, &entry)?;
633        self.changed = true;
634
635        self.entries = definitions::load(Some(&self.library))?;
636        self.mode = Mode::Browse;
637        self.reindex();
638        self.select_id(&id);
639        self.status = Some(if shadowing {
640            format!("Saved {id} to your library, overriding the builtin")
641        } else {
642            format!("Updated {id}")
643        });
644
645        Ok(())
646    }
647
648    /// Takes an entry out of the picker, asking once before it does.
649    ///
650    /// A builtin lives inside the binary and cannot be deleted, so it is added
651    /// to the user's disabled list instead. Either way it stops appearing, which
652    /// is what was asked for.
653    fn remove(&mut self, armed: Option<String>) -> Result<()> {
654        let Some(row) = self.selected_row() else {
655            return Ok(());
656        };
657        let id = row.entry.id.clone();
658        let own = row.entry.layer == Layer::User;
659
660        if armed.as_deref() != Some(id.as_str()) {
661            self.status = Some(format!("Remove {id}? ctrl+x again to confirm"));
662            self.armed_to_remove = Some(id);
663            return Ok(());
664        }
665
666        if own {
667            definitions::remove(&self.library, &id)?;
668        } else {
669            definitions::disable(&self.library, &id)?;
670        }
671        self.stats.forget(&id)?;
672
673        self.changed = true;
674        self.entries = definitions::load(Some(&self.library))?;
675        self.scores = self.stats.scores(self.now)?;
676        self.reindex();
677        self.status = Some(if own {
678            format!("Removed {id}")
679        } else {
680            format!("Hid {id}, listed under disabled in your library")
681        });
682
683        Ok(())
684    }
685
686    fn toggle_pin(&mut self) -> Result<()> {
687        let Some(row) = self.selected_row() else {
688            return Ok(());
689        };
690        let id = row.entry.id.clone();
691        let pinned = !row.pinned;
692
693        self.stats.set_pinned(&id, pinned, self.now)?;
694        self.scores = self.stats.scores(self.now)?;
695        self.reindex();
696        self.select_id(&id);
697        self.status = Some(if pinned { "Pinned" } else { "Unpinned" }.to_string());
698
699        Ok(())
700    }
701
702    fn move_by(&mut self, delta: isize) {
703        if self.order.is_empty() {
704            self.selected = 0;
705            return;
706        }
707        let last = self.order.len() - 1;
708        let target = self.selected as isize + delta;
709        self.selected = target.clamp(0, last as isize) as usize;
710    }
711
712    fn select_id(&mut self, id: &str) {
713        if let Some(position) = self
714            .order
715            .iter()
716            .position(|&index| self.entries[index].id == id)
717        {
718            self.selected = position;
719        }
720    }
721
722    /// Recomputes which entries are on offer and in what order.
723    fn reindex(&mut self) {
724        self.pickable = (0..self.entries.len())
725            .filter(|&index| self.entries[index].cmd_for(self.family).is_some())
726            .collect();
727
728        let candidates: Vec<Candidate<'_>> = self
729            .pickable
730            .iter()
731            .map(|&index| {
732                let entry = &self.entries[index];
733                Candidate {
734                    entry,
735                    cmd: entry.cmd_for(self.family).expect("filtered on this"),
736                }
737            })
738            .collect();
739
740        let ranked = search::rank(&candidates, &self.scores, &self.query);
741        self.order = ranked.into_iter().map(|rank| self.pickable[rank]).collect();
742        self.selected = self.selected.min(self.order.len().saturating_sub(1));
743    }
744}
745
746/// Trims what the shell handed over, drops blanks and repeats, and caps the
747/// list.
748///
749/// A shell history is mostly the same handful of commands run again and again,
750/// and a list where nine rows in ten are `cd ..` is not worth opening.
751fn prepare_history(raw: Vec<String>) -> Vec<String> {
752    let mut seen = BTreeSet::new();
753
754    raw.into_iter()
755        .map(|command| command.trim().to_string())
756        .filter(|command| !command.is_empty() && seen.insert(command.clone()))
757        .take(HISTORY_LIMIT)
758        .collect()
759}
760
761/// One line of the picker.
762pub struct Row<'a> {
763    pub entry: &'a Entry,
764    pub cmd: &'a str,
765    pub pinned: bool,
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::model::{CommandBody, Layer, ParamSpec};
772
773    fn key(code: KeyCode) -> KeyEvent {
774        KeyEvent::new(code, KeyModifiers::NONE)
775    }
776
777    fn ctrl(character: char) -> KeyEvent {
778        KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL)
779    }
780
781    fn typed(app: &mut App, text: &str) {
782        for character in text.chars() {
783            app.on_key(key(KeyCode::Char(character))).unwrap();
784        }
785    }
786
787    fn entry(id: &str, cmd: &str, desc: &str) -> Entry {
788        Entry {
789            id: id.to_string(),
790            cmd: CommandBody::Shared(cmd.to_string()),
791            desc: desc.to_string(),
792            tags: Vec::new(),
793            params: BTreeMap::new(),
794            danger: false,
795            layer: Layer::Builtin,
796        }
797    }
798
799    fn app_with(entries: Vec<Entry>) -> App {
800        app_with_history(entries, vec!["kics scan -p .".to_string()])
801    }
802
803    fn app_with_history(entries: Vec<Entry>, history: Vec<String>) -> App {
804        let library = std::env::temp_dir().join(format!(
805            "lore-app-{}-{:?}.yaml",
806            std::process::id(),
807            std::thread::current().id()
808        ));
809        let _ = std::fs::remove_file(&library);
810        App::new(
811            entries,
812            ShellFamily::Posix,
813            Stats::in_memory().unwrap(),
814            library,
815            history,
816            0,
817        )
818        .unwrap()
819    }
820
821    fn history_sample() -> App {
822        app_with_history(
823            vec![entry("git.log", "git log --oneline", "Show history")],
824            vec![
825                "kics scan -p .".to_string(),
826                "docker compose up -d".to_string(),
827                "git log --oneline".to_string(),
828            ],
829        )
830    }
831
832    fn sample() -> App {
833        app_with(vec![
834            entry("git.log", "git log --oneline", "Show history"),
835            entry("docker.ps", "docker ps -a", "List containers"),
836            entry(
837                "k8s.logs",
838                "kubectl logs -f <pod> -n <namespace:default>",
839                "Follow pod logs",
840            ),
841        ])
842    }
843
844    #[test]
845    fn typing_filters_the_list() {
846        let mut app = sample();
847        assert_eq!(app.matches(), 3);
848        typed(&mut app, "git");
849        assert_eq!(app.matches(), 1);
850        assert_eq!(app.selected_row().unwrap().entry.id, "git.log");
851    }
852
853    /// Pressing ctrl+g after typing something opens the picker on it rather
854    /// than on the whole library.
855    #[test]
856    fn the_picker_can_open_on_a_search_already_typed() {
857        let mut app = sample();
858        app.search("git".to_string());
859
860        assert_eq!(app.query(), "git");
861        assert_eq!(app.matches(), 1);
862        assert_eq!(app.selected_row().unwrap().entry.id, "git.log");
863
864        // And it is an ordinary query from there on.
865        app.on_key(key(KeyCode::Backspace)).unwrap();
866        assert_eq!(app.query(), "gi");
867        app.on_key(ctrl('u')).unwrap();
868        assert_eq!(app.matches(), 3);
869    }
870
871    #[test]
872    fn backspace_widens_the_list_again() {
873        let mut app = sample();
874        typed(&mut app, "git");
875        app.on_key(key(KeyCode::Backspace)).unwrap();
876        app.on_key(key(KeyCode::Backspace)).unwrap();
877        app.on_key(key(KeyCode::Backspace)).unwrap();
878        assert_eq!(app.query(), "");
879        assert_eq!(app.matches(), 3);
880    }
881
882    #[test]
883    fn selection_stays_inside_the_list() {
884        let mut app = sample();
885        for _ in 0..10 {
886            app.on_key(key(KeyCode::Down)).unwrap();
887        }
888        assert_eq!(app.selected(), 2);
889        for _ in 0..10 {
890            app.on_key(key(KeyCode::Up)).unwrap();
891        }
892        assert_eq!(app.selected(), 0);
893    }
894
895    #[test]
896    fn a_narrowed_list_never_leaves_the_cursor_past_the_end() {
897        let mut app = sample();
898        app.on_key(key(KeyCode::Down)).unwrap();
899        app.on_key(key(KeyCode::Down)).unwrap();
900        typed(&mut app, "git");
901        assert_eq!(app.selected(), 0);
902        assert!(app.selected_row().is_some());
903    }
904
905    #[test]
906    fn enter_on_a_plain_command_returns_it() {
907        let mut app = sample();
908        typed(&mut app, "docker");
909        let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
910        assert_eq!(outcome, Some(Outcome::insert("docker ps -a")));
911    }
912
913    #[test]
914    fn escape_cancels_without_choosing() {
915        let mut app = sample();
916        assert_eq!(
917            app.on_key(key(KeyCode::Esc)).unwrap(),
918            Some(Outcome::Cancelled)
919        );
920    }
921
922    #[test]
923    fn the_opening_chord_also_closes_the_picker() {
924        let mut app = sample();
925        assert_eq!(app.on_key(ctrl('g')).unwrap(), Some(Outcome::Cancelled));
926    }
927
928    #[test]
929    fn enter_on_a_parameterised_command_asks_for_values() {
930        let mut app = sample();
931        typed(&mut app, "kubectl");
932        assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
933        assert!(matches!(app.mode(), Mode::Params { .. }));
934    }
935
936    #[test]
937    fn placeholder_defaults_arrive_pre_filled() {
938        let mut app = sample();
939        typed(&mut app, "kubectl");
940        app.on_key(key(KeyCode::Enter)).unwrap();
941
942        let Mode::Params { form, .. } = app.mode() else {
943            panic!("expected the parameter form");
944        };
945        assert_eq!(form.fields[0].label, "pod");
946        assert_eq!(form.fields[0].value, "");
947        assert_eq!(form.fields[1].label, "namespace");
948        assert_eq!(form.fields[1].value, "default");
949    }
950
951    #[test]
952    fn filled_placeholders_produce_the_final_command() {
953        let mut app = sample();
954        typed(&mut app, "kubectl");
955        app.on_key(key(KeyCode::Enter)).unwrap();
956        typed(&mut app, "api-0");
957        app.on_key(key(KeyCode::Enter)).unwrap();
958        let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
959
960        assert_eq!(
961            outcome,
962            Some(Outcome::insert("kubectl logs -f api-0 -n default"))
963        );
964    }
965
966    #[test]
967    fn a_second_use_remembers_the_last_values() {
968        let mut app = sample();
969        typed(&mut app, "kubectl");
970        app.on_key(key(KeyCode::Enter)).unwrap();
971        typed(&mut app, "api-0");
972        app.on_key(key(KeyCode::Enter)).unwrap();
973        app.on_key(key(KeyCode::Enter)).unwrap();
974
975        // Reopening the same entry should not ask for the pod name again.
976        app.mode = Mode::Browse;
977        app.on_key(key(KeyCode::Enter)).unwrap();
978        let Mode::Params { form, .. } = app.mode() else {
979            panic!("expected the parameter form");
980        };
981        assert_eq!(form.fields[0].value, "api-0");
982    }
983
984    #[test]
985    fn escape_leaves_the_parameter_form_without_choosing() {
986        let mut app = sample();
987        typed(&mut app, "kubectl");
988        app.on_key(key(KeyCode::Enter)).unwrap();
989        assert_eq!(app.on_key(key(KeyCode::Esc)).unwrap(), None);
990        assert!(matches!(app.mode(), Mode::Browse));
991    }
992
993    #[test]
994    fn parameter_descriptions_reach_the_form() {
995        let mut with_desc = entry(
996            "two.params",
997            "scan -p <path> --format <format>",
998            "Scan a directory",
999        );
1000        with_desc.params.insert(
1001            "path".to_string(),
1002            ParamSpec {
1003                desc: Some("Directory to scan".to_string()),
1004                from: None,
1005            },
1006        );
1007
1008        let mut app = app_with(vec![with_desc]);
1009        app.on_key(key(KeyCode::Enter)).unwrap();
1010        let Mode::Params { form, .. } = app.mode() else {
1011            panic!("expected the parameter form");
1012        };
1013        assert_eq!(form.fields[0].hint.as_deref(), Some("Directory to scan"));
1014    }
1015
1016    /// A form is a poor place to type a path or a branch name: it knows nothing
1017    /// the shell's own completion knows. One placeholder goes to the prompt
1018    /// instead, with the cursor sitting in the gap it left.
1019    #[test]
1020    fn a_single_placeholder_lands_in_the_prompt_under_the_cursor() {
1021        let mut app = app_with(vec![entry(
1022            "one.param",
1023            "scan -p <path>",
1024            "Scan a directory",
1025        )]);
1026        let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
1027
1028        assert_eq!(
1029            outcome,
1030            Some(Outcome::Insert {
1031                command: "scan -p ".to_string(),
1032                cursor: Some(8),
1033            })
1034        );
1035    }
1036
1037    /// The shortcut is about typing one value, not about skipping the form. A
1038    /// placeholder used twice still has to be filled in one place.
1039    #[test]
1040    fn a_placeholder_used_twice_still_opens_the_form() {
1041        let mut app = app_with(vec![entry(
1042            "twice",
1043            "mv <name> <name>.bak",
1044            "Back a file up in place",
1045        )]);
1046        assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
1047        assert!(matches!(app.mode(), Mode::Params { .. }));
1048    }
1049
1050    #[test]
1051    fn a_command_with_no_placeholder_leaves_the_cursor_alone() {
1052        let mut app = sample();
1053        typed(&mut app, "docker");
1054
1055        let Some(Outcome::Insert { cursor, .. }) = app.on_key(key(KeyCode::Enter)).unwrap() else {
1056            panic!("expected a command");
1057        };
1058        assert_eq!(cursor, None);
1059    }
1060
1061    fn save_mode(app: &App) -> &Save {
1062        let Mode::Save(save) = app.mode() else {
1063            panic!("expected to be saving");
1064        };
1065        save
1066    }
1067
1068    #[test]
1069    fn saving_starts_on_the_newest_command_and_asks_what_it_is_for() {
1070        let mut app = sample();
1071        app.on_key(ctrl('s')).unwrap();
1072
1073        let save = save_mode(&app);
1074        assert_eq!(save.step, SaveStep::Command);
1075        assert_eq!(save.command, "kics scan -p .");
1076
1077        app.on_key(key(KeyCode::Enter)).unwrap();
1078        assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1079
1080        typed(&mut app, "Scan this project");
1081        app.on_key(key(KeyCode::Enter)).unwrap();
1082
1083        assert!(matches!(app.mode(), Mode::Browse));
1084        assert!(app.status().unwrap().contains("user.kics-scan"));
1085        assert!(app.changed());
1086
1087        // Saving reloads from disk, so the entry is now in the ranked list and
1088        // sitting under the cursor ready to be inserted.
1089        let saved = app.selected_row().unwrap();
1090        assert_eq!(saved.entry.id, "user.kics-scan");
1091        assert_eq!(saved.cmd, "kics scan -p .");
1092        assert_eq!(saved.entry.desc, "Scan this project");
1093        assert_eq!(saved.entry.tags, ["kics", "scan"]);
1094
1095        let _ = std::fs::remove_file(&app.library);
1096    }
1097
1098    #[test]
1099    fn hashtags_in_the_answer_become_tags() {
1100        let mut app = sample();
1101        app.on_key(ctrl('s')).unwrap();
1102        app.on_key(key(KeyCode::Enter)).unwrap();
1103        typed(&mut app, "Scan this project #security");
1104
1105        assert_eq!(app.save_tags(), ["security", "kics", "scan"]);
1106        app.on_key(key(KeyCode::Enter)).unwrap();
1107
1108        let saved = app.selected_row().unwrap();
1109        assert_eq!(saved.entry.desc, "Scan this project");
1110        assert_eq!(saved.entry.tags, ["security", "kics", "scan"]);
1111
1112        let _ = std::fs::remove_file(&app.library);
1113    }
1114
1115    #[test]
1116    fn saving_refuses_an_entry_nobody_could_find_later() {
1117        let mut app = sample();
1118        app.on_key(ctrl('s')).unwrap();
1119        app.on_key(key(KeyCode::Enter)).unwrap();
1120        typed(&mut app, "#only-tags");
1121        app.on_key(key(KeyCode::Enter)).unwrap();
1122
1123        assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1124        assert!(app.status().unwrap().contains("what it is for"));
1125        assert!(!app.library.exists(), "an unfindable entry was written");
1126    }
1127
1128    #[test]
1129    fn going_on_without_a_command_is_refused() {
1130        let mut app = app_with_history(Vec::new(), Vec::new());
1131        app.on_key(ctrl('s')).unwrap();
1132        app.on_key(key(KeyCode::Enter)).unwrap();
1133
1134        assert_eq!(save_mode(&app).step, SaveStep::Command);
1135        assert_eq!(app.status(), Some(NO_COMMAND));
1136    }
1137
1138    /// The shell having nothing to offer is not an error. The line is still
1139    /// there to be typed into.
1140    #[test]
1141    fn an_empty_history_still_opens_on_an_empty_line() {
1142        let mut app = app_with_history(
1143            vec![entry("git.log", "git log", "Show history")],
1144            Vec::new(),
1145        );
1146        app.on_key(ctrl('s')).unwrap();
1147
1148        let save = save_mode(&app);
1149        assert_eq!(save.command, "");
1150        assert_eq!(save.recalled, None);
1151        assert!(app.status().is_none());
1152
1153        app.on_key(key(KeyCode::Up)).unwrap();
1154        assert_eq!(
1155            save_mode(&app).command,
1156            "",
1157            "up found history that is not there"
1158        );
1159    }
1160
1161    /// The way a shell's own prompt behaves: up walks back, down walks
1162    /// forward, and down past the newest leaves an empty line.
1163    #[test]
1164    fn up_and_down_walk_the_history_like_a_shell() {
1165        let mut app = history_sample();
1166        app.on_key(ctrl('s')).unwrap();
1167        assert_eq!(save_mode(&app).command, "kics scan -p .");
1168
1169        app.on_key(key(KeyCode::Up)).unwrap();
1170        assert_eq!(save_mode(&app).command, "docker compose up -d");
1171        app.on_key(key(KeyCode::Up)).unwrap();
1172        assert_eq!(save_mode(&app).command, "git log --oneline");
1173        app.on_key(key(KeyCode::Up)).unwrap();
1174        assert_eq!(
1175            save_mode(&app).command,
1176            "git log --oneline",
1177            "ran off the end"
1178        );
1179
1180        app.on_key(key(KeyCode::Down)).unwrap();
1181        app.on_key(key(KeyCode::Down)).unwrap();
1182        assert_eq!(save_mode(&app).command, "kics scan -p .");
1183
1184        app.on_key(key(KeyCode::Down)).unwrap();
1185        let save = save_mode(&app);
1186        assert_eq!(save.command, "");
1187        assert_eq!(save.recalled, None);
1188
1189        app.on_key(key(KeyCode::Up)).unwrap();
1190        assert_eq!(save_mode(&app).command, "kics scan -p .");
1191    }
1192
1193    #[test]
1194    fn a_recalled_command_can_be_edited_before_it_is_saved() {
1195        let mut app = history_sample();
1196        app.on_key(ctrl('s')).unwrap();
1197        app.on_key(key(KeyCode::Up)).unwrap();
1198        for _ in 0.."-d".len() {
1199            app.on_key(key(KeyCode::Backspace)).unwrap();
1200        }
1201        typed(&mut app, "--build");
1202
1203        assert_eq!(save_mode(&app).command, "docker compose up --build");
1204    }
1205
1206    #[test]
1207    fn a_command_already_in_the_library_is_recognised() {
1208        let app = history_sample();
1209        assert!(app.is_saved("git log --oneline"));
1210        assert!(app.is_saved("  git log --oneline "));
1211        assert!(!app.is_saved("docker compose up -d"));
1212    }
1213
1214    /// Up on the second line goes back to the first rather than into the
1215    /// history, and whatever was typed as the answer is kept.
1216    #[test]
1217    fn up_from_the_question_returns_to_the_command() {
1218        let mut app = history_sample();
1219        app.on_key(ctrl('s')).unwrap();
1220        app.on_key(key(KeyCode::Enter)).unwrap();
1221        typed(&mut app, "Scan");
1222        app.on_key(key(KeyCode::Up)).unwrap();
1223
1224        let save = save_mode(&app);
1225        assert_eq!(save.step, SaveStep::Command);
1226        assert_eq!(save.command, "kics scan -p .");
1227        assert_eq!(save.purpose, "Scan");
1228    }
1229
1230    #[test]
1231    fn escape_leaves_without_saving() {
1232        let mut app = history_sample();
1233        app.on_key(ctrl('s')).unwrap();
1234        app.on_key(key(KeyCode::Enter)).unwrap();
1235        typed(&mut app, "Scan this project");
1236        app.on_key(key(KeyCode::Esc)).unwrap();
1237
1238        assert!(matches!(app.mode(), Mode::Browse));
1239        assert!(!app.library.exists());
1240        assert!(!app.changed());
1241    }
1242
1243    #[test]
1244    fn a_repeated_or_blank_history_entry_is_dropped() {
1245        let prepared = prepare_history(vec![
1246            "  git status  ".to_string(),
1247            "   ".to_string(),
1248            "cd ..".to_string(),
1249            "git status".to_string(),
1250        ]);
1251
1252        assert_eq!(
1253            prepared,
1254            vec!["git status".to_string(), "cd ..".to_string()]
1255        );
1256    }
1257
1258    #[test]
1259    fn the_history_is_capped() {
1260        let raw: Vec<String> = (0..HISTORY_LIMIT + 10)
1261            .map(|n| format!("cmd {n}"))
1262            .collect();
1263        assert_eq!(prepare_history(raw).len(), HISTORY_LIMIT);
1264    }
1265
1266    #[test]
1267    fn pinning_floats_an_entry_to_the_top() {
1268        let mut app = sample();
1269        typed(&mut app, "docker");
1270        app.on_key(ctrl('p')).unwrap();
1271
1272        app.on_key(ctrl('u')).unwrap();
1273        assert_eq!(app.selected_row().unwrap().entry.id, "docker.ps");
1274        assert!(app.selected_row().unwrap().pinned);
1275    }
1276
1277    #[test]
1278    fn pinning_twice_unpins() {
1279        let mut app = sample();
1280        app.on_key(ctrl('p')).unwrap();
1281        assert!(app.selected_row().unwrap().pinned);
1282        app.on_key(ctrl('p')).unwrap();
1283        assert!(!app.selected_row().unwrap().pinned);
1284    }
1285
1286    fn save_one(app: &mut App, description: &str) {
1287        app.on_key(ctrl('s')).unwrap();
1288        app.on_key(key(KeyCode::Enter)).unwrap();
1289        typed(app, description);
1290        app.on_key(key(KeyCode::Enter)).unwrap();
1291    }
1292
1293    #[test]
1294    fn editing_opens_on_what_the_entry_already_says() {
1295        let mut app = sample();
1296        let selected = app.selected_row().unwrap();
1297        let id = selected.entry.id.clone();
1298        let cmd = selected.cmd.to_string();
1299        let desc = selected.entry.desc.clone();
1300        app.on_key(ctrl('e')).unwrap();
1301
1302        let Mode::Edit { form, .. } = app.mode() else {
1303            panic!("expected the edit form");
1304        };
1305        assert!(form.title.contains(&id), "the title never names the entry");
1306        assert_eq!(form.fields[0].value, cmd);
1307        assert_eq!(form.fields[1].value, desc);
1308    }
1309
1310    #[test]
1311    fn editing_rewrites_the_entry_and_leaves_it_selected() {
1312        let mut app = app_with(vec![Entry {
1313            layer: Layer::User,
1314            ..entry("user.kics", "kics scan -p .", "Scan this project")
1315        }]);
1316        definitions::append(
1317            &app.library,
1318            &NewEntry {
1319                id: "user.kics".to_string(),
1320                cmd: CommandBody::Shared("kics scan -p .".to_string()),
1321                desc: "Scan this project".to_string(),
1322                tags: Vec::new(),
1323                params: BTreeMap::new(),
1324                danger: false,
1325            },
1326        )
1327        .unwrap();
1328
1329        app.on_key(ctrl('e')).unwrap();
1330        typed(&mut app, " --report-formats json");
1331        app.on_key(key(KeyCode::Enter)).unwrap();
1332        app.on_key(key(KeyCode::Enter)).unwrap();
1333        app.on_key(key(KeyCode::Enter)).unwrap();
1334
1335        assert!(matches!(app.mode(), Mode::Browse));
1336        assert_eq!(app.status(), Some("Updated user.kics"));
1337
1338        let edited = app.selected_row().unwrap();
1339        assert_eq!(edited.entry.id, "user.kics");
1340        assert_eq!(edited.cmd, "kics scan -p . --report-formats json");
1341
1342        let _ = std::fs::remove_file(&app.library);
1343    }
1344
1345    /// A builtin cannot be rewritten inside the binary, so the edit lands in the
1346    /// user's own library under the same id and shadows it from there.
1347    #[test]
1348    fn editing_a_builtin_writes_an_override_the_user_owns() {
1349        let mut app = app_with(vec![entry("git.log", "git log --oneline", "Show history")]);
1350
1351        app.on_key(ctrl('e')).unwrap();
1352        typed(&mut app, " --graph");
1353        app.on_key(key(KeyCode::Enter)).unwrap();
1354        app.on_key(key(KeyCode::Enter)).unwrap();
1355        app.on_key(key(KeyCode::Enter)).unwrap();
1356
1357        assert!(app.status().unwrap().contains("overriding the builtin"));
1358
1359        let text = std::fs::read_to_string(&app.library).unwrap();
1360        assert!(text.contains("id: git.log"), "wrote {text:?}");
1361        assert!(text.contains("--graph"), "wrote {text:?}");
1362
1363        let _ = std::fs::remove_file(&app.library);
1364    }
1365
1366    /// Three fields is two keystrokes of nothing on the way to the one that
1367    /// matters, so the chord submits from wherever the cursor happens to be.
1368    #[test]
1369    fn ctrl_s_submits_the_edit_form_from_any_field() {
1370        let mut app = app_with(vec![entry("git.log", "git log --oneline", "Show history")]);
1371
1372        app.on_key(ctrl('e')).unwrap();
1373        typed(&mut app, " --graph");
1374        app.on_key(ctrl('s')).unwrap();
1375
1376        assert!(matches!(app.mode(), Mode::Browse));
1377        assert_eq!(app.selected_row().unwrap().cmd, "git log --oneline --graph");
1378
1379        let _ = std::fs::remove_file(&app.library);
1380    }
1381
1382    #[test]
1383    fn ctrl_s_saves_from_the_answer_line() {
1384        let mut app = sample();
1385
1386        app.on_key(ctrl('s')).unwrap();
1387        app.on_key(key(KeyCode::Enter)).unwrap();
1388        typed(&mut app, "Scan this project");
1389        app.on_key(ctrl('s')).unwrap();
1390
1391        assert!(matches!(app.mode(), Mode::Browse));
1392        assert!(app.status().unwrap().contains("user.kics-scan"));
1393
1394        let _ = std::fs::remove_file(&app.library);
1395    }
1396
1397    /// Submitting early must not skip the check that the entry is findable.
1398    #[test]
1399    fn the_chord_still_refuses_an_entry_with_no_description() {
1400        let mut app = sample();
1401        app.on_key(ctrl('s')).unwrap();
1402        app.on_key(ctrl('s')).unwrap();
1403
1404        assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1405        assert!(app.status().unwrap().contains("what it is for"));
1406    }
1407
1408    #[test]
1409    fn editing_refuses_an_entry_nobody_could_find_later() {
1410        let mut app = sample();
1411        app.on_key(ctrl('e')).unwrap();
1412        app.on_key(key(KeyCode::Enter)).unwrap();
1413        app.on_key(ctrl('u')).unwrap();
1414        app.on_key(key(KeyCode::Enter)).unwrap();
1415        app.on_key(key(KeyCode::Enter)).unwrap();
1416
1417        assert!(matches!(app.mode(), Mode::Edit { .. }));
1418        assert!(app.status().unwrap().contains("description"));
1419    }
1420
1421    #[test]
1422    fn leaving_the_edit_form_changes_nothing() {
1423        let mut app = sample();
1424        app.on_key(ctrl('e')).unwrap();
1425        typed(&mut app, " --graph");
1426        app.on_key(key(KeyCode::Esc)).unwrap();
1427
1428        assert!(matches!(app.mode(), Mode::Browse));
1429        assert!(
1430            !app.library.exists(),
1431            "an abandoned edit still wrote a file"
1432        );
1433    }
1434
1435    #[test]
1436    fn removing_asks_before_it_does_anything() {
1437        let mut app = sample();
1438        save_one(&mut app, "Scan this project");
1439
1440        app.on_key(ctrl('x')).unwrap();
1441
1442        assert!(app.status().unwrap().contains("ctrl+x again"));
1443        assert!(app.rows().any(|row| row.entry.id == "user.kics-scan"));
1444
1445        let _ = std::fs::remove_file(&app.library);
1446    }
1447
1448    #[test]
1449    fn confirming_takes_the_entry_out_of_the_library() {
1450        let mut app = sample();
1451        save_one(&mut app, "Scan this project");
1452
1453        app.on_key(ctrl('x')).unwrap();
1454        app.on_key(ctrl('x')).unwrap();
1455
1456        assert!(!app.rows().any(|row| row.entry.id == "user.kics-scan"));
1457        let library = std::fs::read_to_string(&app.library).unwrap();
1458        assert!(!library.contains("user.kics-scan"), "left {library:?}");
1459
1460        let _ = std::fs::remove_file(&app.library);
1461    }
1462
1463    #[test]
1464    fn any_other_key_stands_the_confirmation_down() {
1465        let mut app = sample();
1466        save_one(&mut app, "Scan this project");
1467
1468        app.on_key(ctrl('x')).unwrap();
1469        app.on_key(key(KeyCode::Down)).unwrap();
1470        app.on_key(key(KeyCode::Up)).unwrap();
1471        app.on_key(ctrl('x')).unwrap();
1472
1473        // Back to asking rather than removing.
1474        assert!(app.status().unwrap().contains("ctrl+x again"));
1475        assert!(app.rows().any(|row| row.entry.id == "user.kics-scan"));
1476
1477        let _ = std::fs::remove_file(&app.library);
1478    }
1479
1480    /// A builtin lives inside the binary, so removing it means recording that it
1481    /// should stop appearing.
1482    #[test]
1483    fn removing_a_builtin_disables_it_instead() {
1484        let mut app = sample();
1485        let id = app.selected_row().unwrap().entry.id.clone();
1486
1487        app.on_key(ctrl('x')).unwrap();
1488        app.on_key(ctrl('x')).unwrap();
1489
1490        assert!(app.status().unwrap().contains("disabled"));
1491        let library = std::fs::read_to_string(&app.library).unwrap();
1492        assert!(library.contains("disabled:"), "wrote {library:?}");
1493        assert!(library.contains(&id), "wrote {library:?}");
1494
1495        let _ = std::fs::remove_file(&app.library);
1496    }
1497
1498    #[test]
1499    fn removing_forgets_what_was_remembered_about_the_entry() {
1500        let mut app = sample();
1501        save_one(&mut app, "Scan this project");
1502        let id = app.selected_row().unwrap().entry.id.clone();
1503
1504        app.on_key(ctrl('x')).unwrap();
1505        app.on_key(ctrl('x')).unwrap();
1506
1507        assert!(!app.stats.scores(0).unwrap().contains_key(&id));
1508
1509        let _ = std::fs::remove_file(&app.library);
1510    }
1511
1512    #[test]
1513    fn a_query_matching_nothing_leaves_the_picker_usable() {
1514        let mut app = sample();
1515        typed(&mut app, "zzzzz");
1516        assert_eq!(app.matches(), 0);
1517        assert!(app.selected_row().is_none());
1518        assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
1519        app.on_key(key(KeyCode::Down)).unwrap();
1520        assert_eq!(app.selected(), 0);
1521    }
1522}