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 notice(&mut self, message: String) {
537 self.status = Some(message);
538 }
539
540 pub fn changed(&self) -> bool {
542 self.changed
543 }
544
545 fn begin_edit(&mut self) {
551 let Some(row) = self.selected_row() else {
552 return;
553 };
554 let entry = row.entry;
555
556 let form = Form::new(
557 format!("Edit {}", entry.id),
558 vec![
559 Field::new("command", row.cmd.to_string()),
560 Field::new("description", entry.desc.clone()),
561 Field::new("tags", entry.tags.join(", "))
562 .with_hint(Some("comma separated".to_string())),
563 ],
564 );
565
566 self.mode = Mode::Edit {
567 id: entry.id.clone(),
568 cmd: entry.cmd.clone(),
569 params: entry.params.clone(),
570 danger: entry.danger,
571 shadowing: entry.layer != Layer::User,
572 form,
573 };
574 }
575
576 fn finish_edit(&mut self) -> Result<()> {
577 let Mode::Edit {
578 id,
579 cmd,
580 params,
581 danger,
582 shadowing,
583 form,
584 } = &self.mode
585 else {
586 return Ok(());
587 };
588
589 let command = form.value(0).to_string();
590 let description = form.value(1).to_string();
591
592 if command.is_empty() {
593 self.status = Some("A command is required".to_string());
594 return Ok(());
595 }
596 if description.is_empty() {
597 self.status = Some("A description is required to find this later".to_string());
598 if let Mode::Edit { form, .. } = &mut self.mode {
599 form.focused = 1;
600 }
601 return Ok(());
602 }
603
604 let cmd = match cmd {
607 CommandBody::Shared(_) => CommandBody::Shared(command),
608 CommandBody::PerShell(variants) => {
609 let mut variants = variants.clone();
610 variants.insert(self.family, command);
611 CommandBody::PerShell(variants)
612 }
613 };
614
615 let entry = NewEntry {
616 id: id.clone(),
617 cmd,
618 desc: description,
619 tags: definitions::parse_tags(form.value(2)),
620 params: params.clone(),
621 danger: *danger,
622 };
623 let id = entry.id.clone();
624 let shadowing = *shadowing;
625
626 definitions::upsert(&self.library, &entry)?;
627 self.changed = true;
628
629 self.entries = definitions::load(Some(&self.library))?;
630 self.mode = Mode::Browse;
631 self.reindex();
632 self.select_id(&id);
633 self.status = Some(if shadowing {
634 format!("Saved {id} to your library, overriding the builtin")
635 } else {
636 format!("Updated {id}")
637 });
638
639 Ok(())
640 }
641
642 fn remove(&mut self, armed: Option<String>) -> Result<()> {
648 let Some(row) = self.selected_row() else {
649 return Ok(());
650 };
651 let id = row.entry.id.clone();
652 let own = row.entry.layer == Layer::User;
653
654 if armed.as_deref() != Some(id.as_str()) {
655 self.status = Some(format!("Remove {id}? ctrl+x again to confirm"));
656 self.armed_to_remove = Some(id);
657 return Ok(());
658 }
659
660 if own {
661 definitions::remove(&self.library, &id)?;
662 } else {
663 definitions::disable(&self.library, &id)?;
664 }
665 self.stats.forget(&id)?;
666
667 self.changed = true;
668 self.entries = definitions::load(Some(&self.library))?;
669 self.scores = self.stats.scores(self.now)?;
670 self.reindex();
671 self.status = Some(if own {
672 format!("Removed {id}")
673 } else {
674 format!("Hid {id}, listed under disabled in your library")
675 });
676
677 Ok(())
678 }
679
680 fn toggle_pin(&mut self) -> Result<()> {
681 let Some(row) = self.selected_row() else {
682 return Ok(());
683 };
684 let id = row.entry.id.clone();
685 let pinned = !row.pinned;
686
687 self.stats.set_pinned(&id, pinned, self.now)?;
688 self.scores = self.stats.scores(self.now)?;
689 self.reindex();
690 self.select_id(&id);
691 self.status = Some(if pinned { "Pinned" } else { "Unpinned" }.to_string());
692
693 Ok(())
694 }
695
696 fn move_by(&mut self, delta: isize) {
697 if self.order.is_empty() {
698 self.selected = 0;
699 return;
700 }
701 let last = self.order.len() - 1;
702 let target = self.selected as isize + delta;
703 self.selected = target.clamp(0, last as isize) as usize;
704 }
705
706 fn select_id(&mut self, id: &str) {
707 if let Some(position) = self
708 .order
709 .iter()
710 .position(|&index| self.entries[index].id == id)
711 {
712 self.selected = position;
713 }
714 }
715
716 fn reindex(&mut self) {
718 self.pickable = (0..self.entries.len())
719 .filter(|&index| self.entries[index].cmd_for(self.family).is_some())
720 .collect();
721
722 let candidates: Vec<Candidate<'_>> = self
723 .pickable
724 .iter()
725 .map(|&index| {
726 let entry = &self.entries[index];
727 Candidate {
728 entry,
729 cmd: entry.cmd_for(self.family).expect("filtered on this"),
730 }
731 })
732 .collect();
733
734 let ranked = search::rank(&candidates, &self.scores, &self.query);
735 self.order = ranked.into_iter().map(|rank| self.pickable[rank]).collect();
736 self.selected = self.selected.min(self.order.len().saturating_sub(1));
737 }
738}
739
740fn prepare_history(raw: Vec<String>) -> Vec<String> {
746 let mut seen = BTreeSet::new();
747
748 raw.into_iter()
749 .map(|command| command.trim().to_string())
750 .filter(|command| !command.is_empty() && seen.insert(command.clone()))
751 .take(HISTORY_LIMIT)
752 .collect()
753}
754
755pub struct Row<'a> {
757 pub entry: &'a Entry,
758 pub cmd: &'a str,
759 pub pinned: bool,
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use crate::model::{CommandBody, Layer, ParamSpec};
766
767 fn key(code: KeyCode) -> KeyEvent {
768 KeyEvent::new(code, KeyModifiers::NONE)
769 }
770
771 fn ctrl(character: char) -> KeyEvent {
772 KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL)
773 }
774
775 fn typed(app: &mut App, text: &str) {
776 for character in text.chars() {
777 app.on_key(key(KeyCode::Char(character))).unwrap();
778 }
779 }
780
781 fn entry(id: &str, cmd: &str, desc: &str) -> Entry {
782 Entry {
783 id: id.to_string(),
784 cmd: CommandBody::Shared(cmd.to_string()),
785 desc: desc.to_string(),
786 tags: Vec::new(),
787 params: BTreeMap::new(),
788 danger: false,
789 layer: Layer::Builtin,
790 }
791 }
792
793 fn app_with(entries: Vec<Entry>) -> App {
794 app_with_history(entries, vec!["kics scan -p .".to_string()])
795 }
796
797 fn app_with_history(entries: Vec<Entry>, history: Vec<String>) -> App {
798 let library = std::env::temp_dir().join(format!(
799 "lore-app-{}-{:?}.yaml",
800 std::process::id(),
801 std::thread::current().id()
802 ));
803 let _ = std::fs::remove_file(&library);
804 App::new(
805 entries,
806 ShellFamily::Posix,
807 Stats::in_memory().unwrap(),
808 library,
809 history,
810 0,
811 )
812 .unwrap()
813 }
814
815 fn history_sample() -> App {
816 app_with_history(
817 vec![entry("git.log", "git log --oneline", "Show history")],
818 vec![
819 "kics scan -p .".to_string(),
820 "docker compose up -d".to_string(),
821 "git log --oneline".to_string(),
822 ],
823 )
824 }
825
826 fn sample() -> App {
827 app_with(vec![
828 entry("git.log", "git log --oneline", "Show history"),
829 entry("docker.ps", "docker ps -a", "List containers"),
830 entry(
831 "k8s.logs",
832 "kubectl logs -f <pod> -n <namespace:default>",
833 "Follow pod logs",
834 ),
835 ])
836 }
837
838 #[test]
839 fn typing_filters_the_list() {
840 let mut app = sample();
841 assert_eq!(app.matches(), 3);
842 typed(&mut app, "git");
843 assert_eq!(app.matches(), 1);
844 assert_eq!(app.selected_row().unwrap().entry.id, "git.log");
845 }
846
847 #[test]
848 fn backspace_widens_the_list_again() {
849 let mut app = sample();
850 typed(&mut app, "git");
851 app.on_key(key(KeyCode::Backspace)).unwrap();
852 app.on_key(key(KeyCode::Backspace)).unwrap();
853 app.on_key(key(KeyCode::Backspace)).unwrap();
854 assert_eq!(app.query(), "");
855 assert_eq!(app.matches(), 3);
856 }
857
858 #[test]
859 fn selection_stays_inside_the_list() {
860 let mut app = sample();
861 for _ in 0..10 {
862 app.on_key(key(KeyCode::Down)).unwrap();
863 }
864 assert_eq!(app.selected(), 2);
865 for _ in 0..10 {
866 app.on_key(key(KeyCode::Up)).unwrap();
867 }
868 assert_eq!(app.selected(), 0);
869 }
870
871 #[test]
872 fn a_narrowed_list_never_leaves_the_cursor_past_the_end() {
873 let mut app = sample();
874 app.on_key(key(KeyCode::Down)).unwrap();
875 app.on_key(key(KeyCode::Down)).unwrap();
876 typed(&mut app, "git");
877 assert_eq!(app.selected(), 0);
878 assert!(app.selected_row().is_some());
879 }
880
881 #[test]
882 fn enter_on_a_plain_command_returns_it() {
883 let mut app = sample();
884 typed(&mut app, "docker");
885 let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
886 assert_eq!(outcome, Some(Outcome::insert("docker ps -a")));
887 }
888
889 #[test]
890 fn escape_cancels_without_choosing() {
891 let mut app = sample();
892 assert_eq!(
893 app.on_key(key(KeyCode::Esc)).unwrap(),
894 Some(Outcome::Cancelled)
895 );
896 }
897
898 #[test]
899 fn the_opening_chord_also_closes_the_picker() {
900 let mut app = sample();
901 assert_eq!(app.on_key(ctrl('g')).unwrap(), Some(Outcome::Cancelled));
902 }
903
904 #[test]
905 fn enter_on_a_parameterised_command_asks_for_values() {
906 let mut app = sample();
907 typed(&mut app, "kubectl");
908 assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
909 assert!(matches!(app.mode(), Mode::Params { .. }));
910 }
911
912 #[test]
913 fn placeholder_defaults_arrive_pre_filled() {
914 let mut app = sample();
915 typed(&mut app, "kubectl");
916 app.on_key(key(KeyCode::Enter)).unwrap();
917
918 let Mode::Params { form, .. } = app.mode() else {
919 panic!("expected the parameter form");
920 };
921 assert_eq!(form.fields[0].label, "pod");
922 assert_eq!(form.fields[0].value, "");
923 assert_eq!(form.fields[1].label, "namespace");
924 assert_eq!(form.fields[1].value, "default");
925 }
926
927 #[test]
928 fn filled_placeholders_produce_the_final_command() {
929 let mut app = sample();
930 typed(&mut app, "kubectl");
931 app.on_key(key(KeyCode::Enter)).unwrap();
932 typed(&mut app, "api-0");
933 app.on_key(key(KeyCode::Enter)).unwrap();
934 let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
935
936 assert_eq!(
937 outcome,
938 Some(Outcome::insert("kubectl logs -f api-0 -n default"))
939 );
940 }
941
942 #[test]
943 fn a_second_use_remembers_the_last_values() {
944 let mut app = sample();
945 typed(&mut app, "kubectl");
946 app.on_key(key(KeyCode::Enter)).unwrap();
947 typed(&mut app, "api-0");
948 app.on_key(key(KeyCode::Enter)).unwrap();
949 app.on_key(key(KeyCode::Enter)).unwrap();
950
951 app.mode = Mode::Browse;
953 app.on_key(key(KeyCode::Enter)).unwrap();
954 let Mode::Params { form, .. } = app.mode() else {
955 panic!("expected the parameter form");
956 };
957 assert_eq!(form.fields[0].value, "api-0");
958 }
959
960 #[test]
961 fn escape_leaves_the_parameter_form_without_choosing() {
962 let mut app = sample();
963 typed(&mut app, "kubectl");
964 app.on_key(key(KeyCode::Enter)).unwrap();
965 assert_eq!(app.on_key(key(KeyCode::Esc)).unwrap(), None);
966 assert!(matches!(app.mode(), Mode::Browse));
967 }
968
969 #[test]
970 fn parameter_descriptions_reach_the_form() {
971 let mut with_desc = entry(
972 "two.params",
973 "scan -p <path> --format <format>",
974 "Scan a directory",
975 );
976 with_desc.params.insert(
977 "path".to_string(),
978 ParamSpec {
979 desc: Some("Directory to scan".to_string()),
980 from: None,
981 },
982 );
983
984 let mut app = app_with(vec![with_desc]);
985 app.on_key(key(KeyCode::Enter)).unwrap();
986 let Mode::Params { form, .. } = app.mode() else {
987 panic!("expected the parameter form");
988 };
989 assert_eq!(form.fields[0].hint.as_deref(), Some("Directory to scan"));
990 }
991
992 #[test]
996 fn a_single_placeholder_lands_in_the_prompt_under_the_cursor() {
997 let mut app = app_with(vec![entry(
998 "one.param",
999 "scan -p <path>",
1000 "Scan a directory",
1001 )]);
1002 let outcome = app.on_key(key(KeyCode::Enter)).unwrap();
1003
1004 assert_eq!(
1005 outcome,
1006 Some(Outcome::Insert {
1007 command: "scan -p ".to_string(),
1008 cursor: Some(8),
1009 })
1010 );
1011 }
1012
1013 #[test]
1016 fn a_placeholder_used_twice_still_opens_the_form() {
1017 let mut app = app_with(vec![entry(
1018 "twice",
1019 "mv <name> <name>.bak",
1020 "Back a file up in place",
1021 )]);
1022 assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
1023 assert!(matches!(app.mode(), Mode::Params { .. }));
1024 }
1025
1026 #[test]
1027 fn a_command_with_no_placeholder_leaves_the_cursor_alone() {
1028 let mut app = sample();
1029 typed(&mut app, "docker");
1030
1031 let Some(Outcome::Insert { cursor, .. }) = app.on_key(key(KeyCode::Enter)).unwrap() else {
1032 panic!("expected a command");
1033 };
1034 assert_eq!(cursor, None);
1035 }
1036
1037 fn save_mode(app: &App) -> &Save {
1038 let Mode::Save(save) = app.mode() else {
1039 panic!("expected to be saving");
1040 };
1041 save
1042 }
1043
1044 #[test]
1045 fn saving_starts_on_the_newest_command_and_asks_what_it_is_for() {
1046 let mut app = sample();
1047 app.on_key(ctrl('s')).unwrap();
1048
1049 let save = save_mode(&app);
1050 assert_eq!(save.step, SaveStep::Command);
1051 assert_eq!(save.command, "kics scan -p .");
1052
1053 app.on_key(key(KeyCode::Enter)).unwrap();
1054 assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1055
1056 typed(&mut app, "Scan this project");
1057 app.on_key(key(KeyCode::Enter)).unwrap();
1058
1059 assert!(matches!(app.mode(), Mode::Browse));
1060 assert!(app.status().unwrap().contains("user.kics-scan"));
1061 assert!(app.changed());
1062
1063 let saved = app.selected_row().unwrap();
1066 assert_eq!(saved.entry.id, "user.kics-scan");
1067 assert_eq!(saved.cmd, "kics scan -p .");
1068 assert_eq!(saved.entry.desc, "Scan this project");
1069 assert_eq!(saved.entry.tags, ["kics", "scan"]);
1070
1071 let _ = std::fs::remove_file(&app.library);
1072 }
1073
1074 #[test]
1075 fn hashtags_in_the_answer_become_tags() {
1076 let mut app = sample();
1077 app.on_key(ctrl('s')).unwrap();
1078 app.on_key(key(KeyCode::Enter)).unwrap();
1079 typed(&mut app, "Scan this project #security");
1080
1081 assert_eq!(app.save_tags(), ["security", "kics", "scan"]);
1082 app.on_key(key(KeyCode::Enter)).unwrap();
1083
1084 let saved = app.selected_row().unwrap();
1085 assert_eq!(saved.entry.desc, "Scan this project");
1086 assert_eq!(saved.entry.tags, ["security", "kics", "scan"]);
1087
1088 let _ = std::fs::remove_file(&app.library);
1089 }
1090
1091 #[test]
1092 fn saving_refuses_an_entry_nobody_could_find_later() {
1093 let mut app = sample();
1094 app.on_key(ctrl('s')).unwrap();
1095 app.on_key(key(KeyCode::Enter)).unwrap();
1096 typed(&mut app, "#only-tags");
1097 app.on_key(key(KeyCode::Enter)).unwrap();
1098
1099 assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1100 assert!(app.status().unwrap().contains("what it is for"));
1101 assert!(!app.library.exists(), "an unfindable entry was written");
1102 }
1103
1104 #[test]
1105 fn going_on_without_a_command_is_refused() {
1106 let mut app = app_with_history(Vec::new(), Vec::new());
1107 app.on_key(ctrl('s')).unwrap();
1108 app.on_key(key(KeyCode::Enter)).unwrap();
1109
1110 assert_eq!(save_mode(&app).step, SaveStep::Command);
1111 assert_eq!(app.status(), Some(NO_COMMAND));
1112 }
1113
1114 #[test]
1117 fn an_empty_history_still_opens_on_an_empty_line() {
1118 let mut app = app_with_history(
1119 vec![entry("git.log", "git log", "Show history")],
1120 Vec::new(),
1121 );
1122 app.on_key(ctrl('s')).unwrap();
1123
1124 let save = save_mode(&app);
1125 assert_eq!(save.command, "");
1126 assert_eq!(save.recalled, None);
1127 assert!(app.status().is_none());
1128
1129 app.on_key(key(KeyCode::Up)).unwrap();
1130 assert_eq!(
1131 save_mode(&app).command,
1132 "",
1133 "up found history that is not there"
1134 );
1135 }
1136
1137 #[test]
1140 fn up_and_down_walk_the_history_like_a_shell() {
1141 let mut app = history_sample();
1142 app.on_key(ctrl('s')).unwrap();
1143 assert_eq!(save_mode(&app).command, "kics scan -p .");
1144
1145 app.on_key(key(KeyCode::Up)).unwrap();
1146 assert_eq!(save_mode(&app).command, "docker compose up -d");
1147 app.on_key(key(KeyCode::Up)).unwrap();
1148 assert_eq!(save_mode(&app).command, "git log --oneline");
1149 app.on_key(key(KeyCode::Up)).unwrap();
1150 assert_eq!(
1151 save_mode(&app).command,
1152 "git log --oneline",
1153 "ran off the end"
1154 );
1155
1156 app.on_key(key(KeyCode::Down)).unwrap();
1157 app.on_key(key(KeyCode::Down)).unwrap();
1158 assert_eq!(save_mode(&app).command, "kics scan -p .");
1159
1160 app.on_key(key(KeyCode::Down)).unwrap();
1161 let save = save_mode(&app);
1162 assert_eq!(save.command, "");
1163 assert_eq!(save.recalled, None);
1164
1165 app.on_key(key(KeyCode::Up)).unwrap();
1166 assert_eq!(save_mode(&app).command, "kics scan -p .");
1167 }
1168
1169 #[test]
1170 fn a_recalled_command_can_be_edited_before_it_is_saved() {
1171 let mut app = history_sample();
1172 app.on_key(ctrl('s')).unwrap();
1173 app.on_key(key(KeyCode::Up)).unwrap();
1174 for _ in 0.."-d".len() {
1175 app.on_key(key(KeyCode::Backspace)).unwrap();
1176 }
1177 typed(&mut app, "--build");
1178
1179 assert_eq!(save_mode(&app).command, "docker compose up --build");
1180 }
1181
1182 #[test]
1183 fn a_command_already_in_the_library_is_recognised() {
1184 let app = history_sample();
1185 assert!(app.is_saved("git log --oneline"));
1186 assert!(app.is_saved(" git log --oneline "));
1187 assert!(!app.is_saved("docker compose up -d"));
1188 }
1189
1190 #[test]
1193 fn up_from_the_question_returns_to_the_command() {
1194 let mut app = history_sample();
1195 app.on_key(ctrl('s')).unwrap();
1196 app.on_key(key(KeyCode::Enter)).unwrap();
1197 typed(&mut app, "Scan");
1198 app.on_key(key(KeyCode::Up)).unwrap();
1199
1200 let save = save_mode(&app);
1201 assert_eq!(save.step, SaveStep::Command);
1202 assert_eq!(save.command, "kics scan -p .");
1203 assert_eq!(save.purpose, "Scan");
1204 }
1205
1206 #[test]
1207 fn escape_leaves_without_saving() {
1208 let mut app = history_sample();
1209 app.on_key(ctrl('s')).unwrap();
1210 app.on_key(key(KeyCode::Enter)).unwrap();
1211 typed(&mut app, "Scan this project");
1212 app.on_key(key(KeyCode::Esc)).unwrap();
1213
1214 assert!(matches!(app.mode(), Mode::Browse));
1215 assert!(!app.library.exists());
1216 assert!(!app.changed());
1217 }
1218
1219 #[test]
1220 fn a_repeated_or_blank_history_entry_is_dropped() {
1221 let prepared = prepare_history(vec![
1222 " git status ".to_string(),
1223 " ".to_string(),
1224 "cd ..".to_string(),
1225 "git status".to_string(),
1226 ]);
1227
1228 assert_eq!(
1229 prepared,
1230 vec!["git status".to_string(), "cd ..".to_string()]
1231 );
1232 }
1233
1234 #[test]
1235 fn the_history_is_capped() {
1236 let raw: Vec<String> = (0..HISTORY_LIMIT + 10)
1237 .map(|n| format!("cmd {n}"))
1238 .collect();
1239 assert_eq!(prepare_history(raw).len(), HISTORY_LIMIT);
1240 }
1241
1242 #[test]
1243 fn pinning_floats_an_entry_to_the_top() {
1244 let mut app = sample();
1245 typed(&mut app, "docker");
1246 app.on_key(ctrl('p')).unwrap();
1247
1248 app.on_key(ctrl('u')).unwrap();
1249 assert_eq!(app.selected_row().unwrap().entry.id, "docker.ps");
1250 assert!(app.selected_row().unwrap().pinned);
1251 }
1252
1253 #[test]
1254 fn pinning_twice_unpins() {
1255 let mut app = sample();
1256 app.on_key(ctrl('p')).unwrap();
1257 assert!(app.selected_row().unwrap().pinned);
1258 app.on_key(ctrl('p')).unwrap();
1259 assert!(!app.selected_row().unwrap().pinned);
1260 }
1261
1262 fn save_one(app: &mut App, description: &str) {
1263 app.on_key(ctrl('s')).unwrap();
1264 app.on_key(key(KeyCode::Enter)).unwrap();
1265 typed(app, description);
1266 app.on_key(key(KeyCode::Enter)).unwrap();
1267 }
1268
1269 #[test]
1270 fn editing_opens_on_what_the_entry_already_says() {
1271 let mut app = sample();
1272 let selected = app.selected_row().unwrap();
1273 let id = selected.entry.id.clone();
1274 let cmd = selected.cmd.to_string();
1275 let desc = selected.entry.desc.clone();
1276 app.on_key(ctrl('e')).unwrap();
1277
1278 let Mode::Edit { form, .. } = app.mode() else {
1279 panic!("expected the edit form");
1280 };
1281 assert!(form.title.contains(&id), "the title never names the entry");
1282 assert_eq!(form.fields[0].value, cmd);
1283 assert_eq!(form.fields[1].value, desc);
1284 }
1285
1286 #[test]
1287 fn editing_rewrites_the_entry_and_leaves_it_selected() {
1288 let mut app = app_with(vec![Entry {
1289 layer: Layer::User,
1290 ..entry("user.kics", "kics scan -p .", "Scan this project")
1291 }]);
1292 definitions::append(
1293 &app.library,
1294 &NewEntry {
1295 id: "user.kics".to_string(),
1296 cmd: CommandBody::Shared("kics scan -p .".to_string()),
1297 desc: "Scan this project".to_string(),
1298 tags: Vec::new(),
1299 params: BTreeMap::new(),
1300 danger: false,
1301 },
1302 )
1303 .unwrap();
1304
1305 app.on_key(ctrl('e')).unwrap();
1306 typed(&mut app, " --report-formats json");
1307 app.on_key(key(KeyCode::Enter)).unwrap();
1308 app.on_key(key(KeyCode::Enter)).unwrap();
1309 app.on_key(key(KeyCode::Enter)).unwrap();
1310
1311 assert!(matches!(app.mode(), Mode::Browse));
1312 assert_eq!(app.status(), Some("Updated user.kics"));
1313
1314 let edited = app.selected_row().unwrap();
1315 assert_eq!(edited.entry.id, "user.kics");
1316 assert_eq!(edited.cmd, "kics scan -p . --report-formats json");
1317
1318 let _ = std::fs::remove_file(&app.library);
1319 }
1320
1321 #[test]
1324 fn editing_a_builtin_writes_an_override_the_user_owns() {
1325 let mut app = app_with(vec![entry("git.log", "git log --oneline", "Show history")]);
1326
1327 app.on_key(ctrl('e')).unwrap();
1328 typed(&mut app, " --graph");
1329 app.on_key(key(KeyCode::Enter)).unwrap();
1330 app.on_key(key(KeyCode::Enter)).unwrap();
1331 app.on_key(key(KeyCode::Enter)).unwrap();
1332
1333 assert!(app.status().unwrap().contains("overriding the builtin"));
1334
1335 let text = std::fs::read_to_string(&app.library).unwrap();
1336 assert!(text.contains("id: git.log"), "wrote {text:?}");
1337 assert!(text.contains("--graph"), "wrote {text:?}");
1338
1339 let _ = std::fs::remove_file(&app.library);
1340 }
1341
1342 #[test]
1345 fn ctrl_s_submits_the_edit_form_from_any_field() {
1346 let mut app = app_with(vec![entry("git.log", "git log --oneline", "Show history")]);
1347
1348 app.on_key(ctrl('e')).unwrap();
1349 typed(&mut app, " --graph");
1350 app.on_key(ctrl('s')).unwrap();
1351
1352 assert!(matches!(app.mode(), Mode::Browse));
1353 assert_eq!(app.selected_row().unwrap().cmd, "git log --oneline --graph");
1354
1355 let _ = std::fs::remove_file(&app.library);
1356 }
1357
1358 #[test]
1359 fn ctrl_s_saves_from_the_answer_line() {
1360 let mut app = sample();
1361
1362 app.on_key(ctrl('s')).unwrap();
1363 app.on_key(key(KeyCode::Enter)).unwrap();
1364 typed(&mut app, "Scan this project");
1365 app.on_key(ctrl('s')).unwrap();
1366
1367 assert!(matches!(app.mode(), Mode::Browse));
1368 assert!(app.status().unwrap().contains("user.kics-scan"));
1369
1370 let _ = std::fs::remove_file(&app.library);
1371 }
1372
1373 #[test]
1375 fn the_chord_still_refuses_an_entry_with_no_description() {
1376 let mut app = sample();
1377 app.on_key(ctrl('s')).unwrap();
1378 app.on_key(ctrl('s')).unwrap();
1379
1380 assert_eq!(save_mode(&app).step, SaveStep::Purpose);
1381 assert!(app.status().unwrap().contains("what it is for"));
1382 }
1383
1384 #[test]
1385 fn editing_refuses_an_entry_nobody_could_find_later() {
1386 let mut app = sample();
1387 app.on_key(ctrl('e')).unwrap();
1388 app.on_key(key(KeyCode::Enter)).unwrap();
1389 app.on_key(ctrl('u')).unwrap();
1390 app.on_key(key(KeyCode::Enter)).unwrap();
1391 app.on_key(key(KeyCode::Enter)).unwrap();
1392
1393 assert!(matches!(app.mode(), Mode::Edit { .. }));
1394 assert!(app.status().unwrap().contains("description"));
1395 }
1396
1397 #[test]
1398 fn leaving_the_edit_form_changes_nothing() {
1399 let mut app = sample();
1400 app.on_key(ctrl('e')).unwrap();
1401 typed(&mut app, " --graph");
1402 app.on_key(key(KeyCode::Esc)).unwrap();
1403
1404 assert!(matches!(app.mode(), Mode::Browse));
1405 assert!(
1406 !app.library.exists(),
1407 "an abandoned edit still wrote a file"
1408 );
1409 }
1410
1411 #[test]
1412 fn removing_asks_before_it_does_anything() {
1413 let mut app = sample();
1414 save_one(&mut app, "Scan this project");
1415
1416 app.on_key(ctrl('x')).unwrap();
1417
1418 assert!(app.status().unwrap().contains("ctrl+x again"));
1419 assert!(app.rows().any(|row| row.entry.id == "user.kics-scan"));
1420
1421 let _ = std::fs::remove_file(&app.library);
1422 }
1423
1424 #[test]
1425 fn confirming_takes_the_entry_out_of_the_library() {
1426 let mut app = sample();
1427 save_one(&mut app, "Scan this project");
1428
1429 app.on_key(ctrl('x')).unwrap();
1430 app.on_key(ctrl('x')).unwrap();
1431
1432 assert!(!app.rows().any(|row| row.entry.id == "user.kics-scan"));
1433 let library = std::fs::read_to_string(&app.library).unwrap();
1434 assert!(!library.contains("user.kics-scan"), "left {library:?}");
1435
1436 let _ = std::fs::remove_file(&app.library);
1437 }
1438
1439 #[test]
1440 fn any_other_key_stands_the_confirmation_down() {
1441 let mut app = sample();
1442 save_one(&mut app, "Scan this project");
1443
1444 app.on_key(ctrl('x')).unwrap();
1445 app.on_key(key(KeyCode::Down)).unwrap();
1446 app.on_key(key(KeyCode::Up)).unwrap();
1447 app.on_key(ctrl('x')).unwrap();
1448
1449 assert!(app.status().unwrap().contains("ctrl+x again"));
1451 assert!(app.rows().any(|row| row.entry.id == "user.kics-scan"));
1452
1453 let _ = std::fs::remove_file(&app.library);
1454 }
1455
1456 #[test]
1459 fn removing_a_builtin_disables_it_instead() {
1460 let mut app = sample();
1461 let id = app.selected_row().unwrap().entry.id.clone();
1462
1463 app.on_key(ctrl('x')).unwrap();
1464 app.on_key(ctrl('x')).unwrap();
1465
1466 assert!(app.status().unwrap().contains("disabled"));
1467 let library = std::fs::read_to_string(&app.library).unwrap();
1468 assert!(library.contains("disabled:"), "wrote {library:?}");
1469 assert!(library.contains(&id), "wrote {library:?}");
1470
1471 let _ = std::fs::remove_file(&app.library);
1472 }
1473
1474 #[test]
1475 fn removing_forgets_what_was_remembered_about_the_entry() {
1476 let mut app = sample();
1477 save_one(&mut app, "Scan this project");
1478 let id = app.selected_row().unwrap().entry.id.clone();
1479
1480 app.on_key(ctrl('x')).unwrap();
1481 app.on_key(ctrl('x')).unwrap();
1482
1483 assert!(!app.stats.scores(0).unwrap().contains_key(&id));
1484
1485 let _ = std::fs::remove_file(&app.library);
1486 }
1487
1488 #[test]
1489 fn a_query_matching_nothing_leaves_the_picker_usable() {
1490 let mut app = sample();
1491 typed(&mut app, "zzzzz");
1492 assert_eq!(app.matches(), 0);
1493 assert!(app.selected_row().is_none());
1494 assert_eq!(app.on_key(key(KeyCode::Enter)).unwrap(), None);
1495 app.on_key(key(KeyCode::Down)).unwrap();
1496 assert_eq!(app.selected(), 0);
1497 }
1498}