1use 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
17const NO_COMMAND: &str = "Type a command, or press up for the ones you ran";
19
20const HISTORY_LIMIT: usize = 50;
25
26#[derive(Debug, PartialEq, Eq)]
28pub enum Outcome {
29 Insert {
31 command: String,
32 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 Edit {
62 id: String,
63 cmd: CommandBody,
64 params: BTreeMap<String, ParamSpec>,
65 danger: bool,
66 shadowing: bool,
69 form: Form,
70 },
71}
72
73pub struct Save {
78 pub step: SaveStep,
79 pub command: String,
80 pub purpose: String,
81 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 pickable: Vec<usize>,
106 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 history: Vec<String>,
117 armed_to_remove: Option<String>,
119 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 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 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 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 (_, 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 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 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 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 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 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 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 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 pub fn search(&mut self, query: String) {
537 self.query = query;
538 self.reindex();
539 }
540
541 pub fn notice(&mut self, message: String) {
543 self.status = Some(message);
544 }
545
546 pub fn changed(&self) -> bool {
548 self.changed
549 }
550
551 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 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 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 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
746fn 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
761pub 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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}