1use std::fs;
22use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ParsedCommand {
34 Mkdir(String),
36 Touch(String),
38 Rename(String),
40 Mv(String),
42 Cp(String),
44 MvMatching {
46 pattern: String,
48 dest: String,
51 },
52 CpMatching {
54 pattern: String,
56 dest: String,
58 },
59 Git(String),
63 Set { key: String, value: String },
65 Sort {
70 by: Option<crate::app::sort::SortBy>,
72 reverse: bool,
74 },
75 Shell(String),
77 Bookmark(String),
80 Jump(String),
82 Plugin { name: String, arg: String },
84 Term(crate::terminal::TermCommand),
87 Unmark,
89 ConfigWrite,
92}
93
94#[derive(Debug, Error, Clone, PartialEq, Eq)]
98pub enum ParseError {
99 #[error("unknown command '{0}' — try :mkdir, :touch, :rename, :mv, :cp, :git, :set, :sort, :bookmark, :jump, :unmark, :config, :plugin, :term")]
101 UnknownVerb(String),
102 #[error("{0} requires an argument")]
104 MissingArgument(String),
105 #[error("{0}")]
107 InvalidArgument(String),
108 #[error("empty command")]
110 Empty,
111}
112
113pub fn parse(buffer: &str, is_shell: bool) -> Result<ParsedCommand, ParseError> {
126 if is_shell {
127 let cmd = buffer.trim();
128 if cmd.is_empty() {
129 return Err(ParseError::MissingArgument("!".to_owned()));
130 }
131 return Ok(ParsedCommand::Shell(cmd.to_owned()));
132 }
133
134 let trimmed = buffer.trim();
135 if trimmed.is_empty() {
136 return Err(ParseError::Empty);
137 }
138
139 let (verb, rest) = match trimmed.split_once(' ') {
141 Some((v, r)) => (v, r.trim()),
142 None => (trimmed, ""),
143 };
144
145 match verb {
146 "mkdir" => {
147 if rest.is_empty() {
148 return Err(ParseError::MissingArgument("mkdir".to_owned()));
149 }
150 let name = rest.to_owned();
153 if name.contains('/') || name.contains('\\') {
154 return Err(ParseError::InvalidArgument(
155 "mkdir: name must not contain path separators".to_owned(),
156 ));
157 }
158 Ok(ParsedCommand::Mkdir(name))
159 }
160
161 "touch" => {
162 if rest.is_empty() {
163 return Err(ParseError::MissingArgument("touch".to_owned()));
164 }
165 let name = rest.to_owned();
166 if name.contains('/') || name.contains('\\') {
167 return Err(ParseError::InvalidArgument(
168 "touch: name must not contain path separators".to_owned(),
169 ));
170 }
171 Ok(ParsedCommand::Touch(name))
172 }
173
174 "rename" | "ren" => {
175 if rest.is_empty() {
176 return Err(ParseError::MissingArgument("rename".to_owned()));
177 }
178 let new_name = rest.to_owned();
179 if new_name.contains('/') || new_name.contains('\\') {
180 return Err(ParseError::InvalidArgument(
181 "rename: new name must not contain path separators".to_owned(),
182 ));
183 }
184 Ok(ParsedCommand::Rename(new_name))
185 }
186
187 "mv" => {
188 if rest.is_empty() {
189 return Err(ParseError::MissingArgument("mv".to_owned()));
190 }
191 match split_pattern_and_dest("mv", rest)? {
192 Some((pattern, dest)) => Ok(ParsedCommand::MvMatching { pattern, dest }),
193 None => Ok(ParsedCommand::Mv(rest.to_owned())),
194 }
195 }
196
197 "cp" => {
198 if rest.is_empty() {
199 return Err(ParseError::MissingArgument("cp".to_owned()));
200 }
201 match split_pattern_and_dest("cp", rest)? {
202 Some((pattern, dest)) => Ok(ParsedCommand::CpMatching { pattern, dest }),
203 None => Ok(ParsedCommand::Cp(rest.to_owned())),
204 }
205 }
206
207 "git" => {
208 if rest.is_empty() {
209 return Err(ParseError::MissingArgument("git".to_owned()));
210 }
211 Ok(ParsedCommand::Git(rest.to_owned()))
212 }
213
214 "set" => {
215 let (key, value) = match rest.split_once(' ') {
217 Some((k, v)) => (k.trim(), v.trim()),
218 None => {
219 return Err(ParseError::MissingArgument(
220 "set requires <key> <value>".to_owned(),
221 ));
222 }
223 };
224 if key.is_empty() {
225 return Err(ParseError::MissingArgument("set: key is empty".to_owned()));
226 }
227 if value.is_empty() {
228 return Err(ParseError::MissingArgument(
229 "set: value is empty".to_owned(),
230 ));
231 }
232 Ok(ParsedCommand::Set {
233 key: key.to_owned(),
234 value: value.to_owned(),
235 })
236 }
237
238 "sort" => {
239 let mut by = None;
243 let mut reverse = false;
244 for word in rest.split_whitespace() {
245 if word.eq_ignore_ascii_case("reverse") || word.eq_ignore_ascii_case("rev") {
246 reverse = true;
247 } else if let Some(key) = crate::app::sort::SortBy::parse(word) {
248 by = Some(key);
249 } else {
250 return Err(ParseError::InvalidArgument(format!(
251 "sort: '{word}' is not a sort key ({})",
252 crate::app::sort::SORT_BY_REASON
253 )));
254 }
255 }
256 if by.is_none() && !reverse {
257 return Err(ParseError::MissingArgument(
258 "sort requires a key (name, size, modified, extension) or 'reverse'".to_owned(),
259 ));
260 }
261 Ok(ParsedCommand::Sort { by, reverse })
262 }
263
264 "bookmark" | "bm" => {
265 let name = rest.trim().to_owned();
268 if name.contains('/') || name.contains('\\') {
269 return Err(ParseError::InvalidArgument(
270 "bookmark: name must not contain path separators".to_owned(),
271 ));
272 }
273 Ok(ParsedCommand::Bookmark(name))
274 }
275
276 "jump" | "j" => {
277 let name = rest.trim();
279 if name.is_empty() {
280 return Err(ParseError::MissingArgument("jump".to_owned()));
281 }
282 Ok(ParsedCommand::Jump(name.to_owned()))
283 }
284
285 "plugin" => {
286 let rest_trimmed = rest.trim();
288 if rest_trimmed.is_empty() {
289 return Err(ParseError::MissingArgument("plugin".to_owned()));
290 }
291 let (name, arg) = match rest_trimmed.split_once(' ') {
292 Some((n, a)) => (n.trim(), a.trim()),
293 None => (rest_trimmed, ""),
294 };
295 if name.is_empty() {
296 return Err(ParseError::MissingArgument("plugin".to_owned()));
297 }
298 Ok(ParsedCommand::Plugin {
299 name: name.to_owned(),
300 arg: arg.to_owned(),
301 })
302 }
303
304 "term" => parse_term(rest),
305
306 "config" => match rest {
307 "write" => Ok(ParsedCommand::ConfigWrite),
308 "" => Err(ParseError::MissingArgument(
309 "config needs a subcommand: write".to_owned(),
310 )),
311 other => Err(ParseError::InvalidArgument(format!(
312 "config: '{other}' is not a subcommand (write)"
313 ))),
314 },
315
316 "unmark" => {
317 if !rest.is_empty() {
318 return Err(ParseError::InvalidArgument(
319 "unmark takes no argument".to_owned(),
320 ));
321 }
322 Ok(ParsedCommand::Unmark)
323 }
324
325 other => Err(ParseError::UnknownVerb(other.to_owned())),
326 }
327}
328
329fn parse_term(rest: &str) -> Result<ParsedCommand, ParseError> {
331 use crate::terminal::TermCommand;
332
333 let mut words = rest.split_whitespace();
334 let command = match words.next() {
335 None => TermCommand::Toggle,
336 Some("new") => TermCommand::New(words.next().map(str::to_owned)),
337 Some("close") => TermCommand::Close,
338 Some("max") => TermCommand::Max,
339 Some(word) => match word.parse::<usize>() {
340 Ok(n) if n > 0 => TermCommand::Select(n),
341 _ => {
342 return Err(ParseError::InvalidArgument(format!(
343 "term: '{word}' is not new, close, max or a shell number"
344 )))
345 }
346 },
347 };
348 if let Some(extra) = words.next() {
349 return Err(ParseError::InvalidArgument(format!(
350 "term: unexpected '{extra}'"
351 )));
352 }
353 Ok(ParsedCommand::Term(command))
354}
355
356fn split_pattern_and_dest(verb: &str, rest: &str) -> Result<Option<(String, String)>, ParseError> {
371 let (first, remainder) = match rest.split_once(char::is_whitespace) {
372 Some((first, remainder)) => (first, remainder.trim()),
373 None => (rest, ""),
374 };
375
376 if !crate::actions::fs_ops::is_pattern(first) {
377 return Ok(None);
378 }
379 if remainder.is_empty() {
380 return Err(ParseError::MissingArgument(format!(
381 "{verb} {first} needs a destination directory"
382 )));
383 }
384 Ok(Some((first.to_owned(), remainder.to_owned())))
385}
386
387#[allow(dead_code)]
406pub fn completions(buffer: &str, cwd: &Path, is_shell: bool) -> Vec<String> {
407 completions_with_sources(buffer, cwd, is_shell, &CompletionSources::default())
408}
409
410#[derive(Debug, Default, Clone, Copy)]
413pub struct CompletionSources<'a> {
414 pub plugin_actions: &'a [&'a str],
416 pub bookmarks: &'a [(String, PathBuf)],
418}
419
420pub fn completions_with_sources(
423 buffer: &str,
424 cwd: &Path,
425 is_shell: bool,
426 sources: &CompletionSources<'_>,
427) -> Vec<String> {
428 if is_shell {
429 return Vec::new(); }
431
432 let trimmed = buffer.trim_start();
433
434 if !trimmed.contains(' ') {
436 let prefix = trimmed;
437 let verbs = [
438 "mkdir", "touch", "rename", "mv", "cp", "git", "set", "sort", "bookmark", "jump",
439 "unmark", "config", "plugin", "term",
440 ];
441 return verbs
442 .iter()
443 .filter(|v| v.starts_with(prefix))
444 .map(|v| format!("{v} "))
445 .collect();
446 }
447
448 let (verb, partial) = match trimmed.split_once(' ') {
450 Some((v, p)) => (v, p),
451 None => return Vec::new(),
452 };
453
454 if verb == "plugin" {
455 let mut candidates: Vec<String> = sources
456 .plugin_actions
457 .iter()
458 .filter(|a| partial.is_empty() || a.starts_with(partial))
459 .map(|a| a.to_string())
460 .collect();
461 candidates.sort();
462 return candidates;
463 }
464
465 if !matches!(verb, "mv" | "cp") {
466 return Vec::new();
467 }
468
469 if let Some(after_at) = partial.strip_prefix('@') {
470 return bookmark_completions(after_at, sources.bookmarks);
471 }
472
473 path_completions(partial, cwd)
475}
476
477fn bookmark_completions(after_at: &str, bookmarks: &[(String, PathBuf)]) -> Vec<String> {
483 match after_at.find(std::path::is_separator) {
484 None => {
485 let mut names: Vec<String> = bookmarks
486 .iter()
487 .filter(|(name, _)| name.starts_with(after_at))
488 .map(|(name, _)| format!("@{name}{}", std::path::MAIN_SEPARATOR))
489 .collect();
490 names.sort();
491 names
492 }
493 Some(split) => {
494 let (name, route) = after_at.split_at(split);
495 let Some((_, base)) = bookmarks.iter().find(|(n, _)| n == name) else {
496 return Vec::new();
497 };
498 let (sep, inner) = route.split_at(1);
501 let respell = !inner.contains(std::path::is_separator);
505 path_completions(inner, base)
506 .into_iter()
507 .map(
508 |candidate| match candidate.strip_suffix(std::path::MAIN_SEPARATOR) {
509 Some(dir) if respell => format!("@{name}{sep}{dir}{sep}"),
510 _ => format!("@{name}{sep}{candidate}"),
511 },
512 )
513 .collect()
514 }
515 }
516}
517
518fn path_completions(partial: &str, cwd: &Path) -> Vec<String> {
533 let split_at = partial.rfind(std::path::is_separator).map_or(0, |i| i + 1);
537 let (route, name_prefix) = partial.split_at(split_at);
538
539 let search_dir = if route.is_empty() {
540 cwd.to_owned()
541 } else {
542 cwd.join(route)
546 };
547
548 let read = match fs::read_dir(&search_dir) {
549 Ok(r) => r,
550 Err(_) => return Vec::new(),
551 };
552
553 let separator = route
554 .chars()
555 .next_back()
556 .filter(|c| std::path::is_separator(*c))
557 .unwrap_or(std::path::MAIN_SEPARATOR);
558
559 let mut candidates: Vec<String> = read
560 .filter_map(|e| e.ok())
561 .filter_map(|entry| {
562 let name = entry.file_name().into_string().ok()?;
563 if !name.starts_with(name_prefix) {
564 return None;
565 }
566 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
567 if is_dir {
568 Some(format!("{route}{name}{separator}"))
569 } else {
570 Some(format!("{route}{name}"))
571 }
572 })
573 .collect();
574
575 candidates.sort();
576 candidates
577}
578
579const HISTORY_CAPACITY: usize = 100;
583
584#[derive(Debug, Default)]
590pub struct CommandHistory {
591 entries: Vec<String>,
593 path: Option<PathBuf>,
595}
596
597impl CommandHistory {
598 pub fn new() -> Self {
600 CommandHistory {
601 entries: Vec::new(),
602 path: None,
603 }
604 }
605
606 #[allow(dead_code)]
615 pub fn with_path(path: PathBuf) -> Self {
616 let entries = load_history(&path).unwrap_or_default();
617 CommandHistory {
618 entries,
619 path: Some(path),
620 }
621 }
622
623 pub fn push(&mut self, command: String) {
629 if command.is_empty() {
630 return;
631 }
632 if self.entries.last().map(|s| s.as_str()) == Some(command.as_str()) {
634 return;
635 }
636 if self.entries.len() >= HISTORY_CAPACITY {
638 self.entries.remove(0);
639 }
640 self.entries.push(command);
641
642 if let Some(ref path) = self.path {
643 if let Err(e) = save_history(path, &self.entries) {
644 tracing::debug!("failed to save command history to {path:?}: {e}");
645 }
646 }
647 }
648
649 pub fn len(&self) -> usize {
651 self.entries.len()
652 }
653
654 pub fn is_empty(&self) -> bool {
656 self.entries.is_empty()
657 }
658
659 pub fn get(&self, index: usize) -> Option<&str> {
663 self.entries.get(index).map(String::as_str)
664 }
665
666 #[allow(dead_code)]
672 pub fn prev(&self, offset: usize) -> Option<&str> {
673 let n = self.entries.len();
674 n.checked_sub(offset + 1).and_then(|i| self.get(i))
675 }
676}
677
678#[allow(dead_code)]
682fn load_history(path: &Path) -> Option<Vec<String>> {
683 let content = fs::read_to_string(path).ok()?;
684 let entries: Vec<String> = content
685 .lines()
686 .filter(|l| !l.is_empty())
687 .map(|l| l.to_owned())
688 .collect();
689 let start = entries.len().saturating_sub(HISTORY_CAPACITY);
691 Some(entries[start..].to_vec())
692}
693
694fn save_history(path: &Path, entries: &[String]) -> std::io::Result<()> {
696 if let Some(parent) = path.parent() {
698 if !parent.exists() {
699 fs::create_dir_all(parent)?;
700 }
701 }
702 let content = entries.join("\n");
703 fs::write(path, content.as_bytes())?;
704 Ok(())
705}
706
707#[derive(Debug, Clone, PartialEq, Eq)]
714pub enum FeedResult {
715 Updated,
717 Submit(String),
719 Cancel,
721 Completion {
724 candidates: Vec<String>,
725 index: usize,
726 },
727}
728
729#[allow(dead_code)]
742#[allow(clippy::too_many_arguments)]
743pub fn feed(
744 key: crossterm::event::KeyEvent,
745 buffer: &mut String,
746 cursor: &mut usize,
747 history_index: &mut Option<usize>,
748 tab_state: &mut TabState,
749 history: &CommandHistory,
750 cwd: &Path,
751 is_shell: bool,
752) -> FeedResult {
753 feed_with_sources(
754 key,
755 buffer,
756 cursor,
757 history_index,
758 tab_state,
759 history,
760 cwd,
761 is_shell,
762 &CompletionSources::default(),
763 )
764}
765
766#[allow(clippy::too_many_arguments)]
769pub fn feed_with_sources(
770 key: crossterm::event::KeyEvent,
771 buffer: &mut String,
772 cursor: &mut usize,
773 history_index: &mut Option<usize>,
774 tab_state: &mut TabState,
775 history: &CommandHistory,
776 cwd: &Path,
777 is_shell: bool,
778 sources: &CompletionSources<'_>,
779) -> FeedResult {
780 use crossterm::event::{KeyCode, KeyModifiers};
781
782 match key.code {
783 KeyCode::Esc => FeedResult::Cancel,
784
785 KeyCode::Enter => {
786 FeedResult::Submit(buffer.clone())
788 }
789
790 KeyCode::Backspace => {
791 if *cursor > 0 {
793 let new_cursor = prev_char_boundary(buffer, *cursor);
795 buffer.drain(new_cursor..*cursor);
796 *cursor = new_cursor;
797 tab_state.reset();
798 }
799 FeedResult::Updated
800 }
801
802 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
803 if *cursor > 0 {
805 let new_cursor = prev_char_boundary(buffer, *cursor);
807 buffer.drain(new_cursor..*cursor);
808 *cursor = new_cursor;
809 tab_state.reset();
810 }
811 FeedResult::Updated
812 }
813
814 KeyCode::Delete => {
815 if *cursor < buffer.len() {
817 let next = next_char_boundary(buffer, *cursor);
818 buffer.drain(*cursor..next);
819 tab_state.reset();
820 }
821 FeedResult::Updated
822 }
823
824 KeyCode::Left => {
825 if *cursor > 0 {
826 *cursor = prev_char_boundary(buffer, *cursor);
827 }
828 tab_state.reset();
831 FeedResult::Updated
832 }
833
834 KeyCode::Right => {
835 if *cursor < buffer.len() {
836 *cursor = next_char_boundary(buffer, *cursor);
837 }
838 tab_state.reset();
839 FeedResult::Updated
840 }
841
842 KeyCode::Up => {
843 let next_idx = match *history_index {
845 None => {
846 if history.is_empty() {
847 return FeedResult::Updated;
848 }
849 history.len() - 1
850 }
851 Some(i) => i.saturating_sub(1),
852 };
853 if let Some(entry) = history.get(next_idx) {
854 *buffer = entry.to_owned();
855 *cursor = buffer.len();
856 *history_index = Some(next_idx);
857 }
858 tab_state.reset();
859 FeedResult::Updated
860 }
861
862 KeyCode::Down => {
863 match *history_index {
865 None => FeedResult::Updated,
866 Some(i) => {
867 if i + 1 < history.len() {
868 let next_idx = i + 1;
869 if let Some(entry) = history.get(next_idx) {
870 *buffer = entry.to_owned();
871 *cursor = buffer.len();
872 *history_index = Some(next_idx);
873 }
874 } else {
875 buffer.clear();
877 *cursor = 0;
878 *history_index = None;
879 }
880 tab_state.reset();
881 FeedResult::Updated
882 }
883 }
884 }
885
886 KeyCode::Tab => {
887 let prefix = tab_state.cycle_prefix(buffer);
892 let candidates = completions_with_sources(&prefix, cwd, is_shell, sources);
893 if candidates.is_empty() {
894 return FeedResult::Updated;
895 }
896 let idx = tab_state.advance(candidates.len());
898 apply_completion(buffer, cursor, &prefix, &candidates[idx], is_shell);
900 FeedResult::Completion {
901 candidates,
902 index: idx,
903 }
904 }
905
906 KeyCode::Home => {
907 *cursor = 0;
908 tab_state.reset();
909 FeedResult::Updated
910 }
911
912 KeyCode::End => {
913 *cursor = buffer.len();
914 tab_state.reset();
915 FeedResult::Updated
916 }
917
918 KeyCode::Char(ch)
921 if crate::input::is_text_modifiers(key.modifiers)
922 || key.modifiers.contains(KeyModifiers::ALT) =>
923 {
924 buffer.insert(*cursor, ch);
926 *cursor += ch.len_utf8();
927 *history_index = None;
928 tab_state.reset();
929 FeedResult::Updated
930 }
931
932 _ => FeedResult::Updated,
933 }
934}
935
936#[derive(Debug, Default)]
940pub struct TabState {
941 index: Option<usize>,
943 prefix: Option<String>,
952}
953
954impl TabState {
955 pub fn new() -> Self {
957 TabState {
958 index: None,
959 prefix: None,
960 }
961 }
962
963 pub fn reset(&mut self) {
965 self.index = None;
966 self.prefix = None;
967 }
968
969 pub fn cycle_prefix(&mut self, buffer: &str) -> String {
976 self.prefix.get_or_insert_with(|| buffer.to_owned()).clone()
977 }
978
979 pub fn advance(&mut self, len: usize) -> usize {
983 let next = match self.index {
984 None => 0,
985 Some(i) => (i + 1) % len,
986 };
987 self.index = Some(next);
988 next
989 }
990}
991
992fn apply_completion(
1001 buffer: &mut String,
1002 cursor: &mut usize,
1003 prefix: &str,
1004 candidate: &str,
1005 is_shell: bool,
1006) {
1007 if is_shell {
1008 return; }
1010 *buffer = match prefix.rfind(' ') {
1011 Some(last_space) => format!("{}{candidate}", &prefix[..=last_space]),
1012 None => candidate.to_owned(),
1013 };
1014 *cursor = buffer.len();
1015}
1016
1017fn prev_char_boundary(s: &str, cursor: usize) -> usize {
1022 let mut pos = cursor.saturating_sub(1);
1023 while pos > 0 && !s.is_char_boundary(pos) {
1024 pos -= 1;
1025 }
1026 pos
1027}
1028
1029fn next_char_boundary(s: &str, cursor: usize) -> usize {
1031 let mut pos = cursor + 1;
1032 while pos < s.len() && !s.is_char_boundary(pos) {
1033 pos += 1;
1034 }
1035 pos
1036}
1037
1038#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1047 fn parse_mkdir_valid() {
1048 let cmd = parse("mkdir foo", false).unwrap();
1049 assert_eq!(cmd, ParsedCommand::Mkdir("foo".to_owned()));
1050 }
1051
1052 #[test]
1053 fn parse_mkdir_empty_arg_is_error() {
1054 let err = parse("mkdir", false).unwrap_err();
1055 assert!(matches!(err, ParseError::MissingArgument(_)));
1056 }
1057
1058 #[test]
1059 fn parse_mkdir_with_separator_is_error() {
1060 let err = parse("mkdir foo/bar", false).unwrap_err();
1061 assert!(matches!(err, ParseError::InvalidArgument(_)));
1062 }
1063
1064 #[test]
1065 fn parse_touch_valid() {
1066 let cmd = parse("touch new_file.txt", false).unwrap();
1067 assert_eq!(cmd, ParsedCommand::Touch("new_file.txt".to_owned()));
1068 }
1069
1070 #[test]
1071 fn parse_rename_valid() {
1072 let cmd = parse("rename new_name", false).unwrap();
1073 assert_eq!(cmd, ParsedCommand::Rename("new_name".to_owned()));
1074 }
1075
1076 #[test]
1077 fn parse_rename_alias_ren() {
1078 let cmd = parse("ren new_name", false).unwrap();
1079 assert_eq!(cmd, ParsedCommand::Rename("new_name".to_owned()));
1080 }
1081
1082 #[test]
1083 fn parse_rename_with_slash_is_error() {
1084 let err = parse("rename foo/bar", false).unwrap_err();
1085 assert!(matches!(err, ParseError::InvalidArgument(_)));
1086 }
1087
1088 #[test]
1089 fn parse_mv_valid() {
1090 let cmd = parse("mv ../somewhere", false).unwrap();
1091 assert_eq!(cmd, ParsedCommand::Mv("../somewhere".to_owned()));
1092 }
1093
1094 #[test]
1095 fn parse_cp_valid() {
1096 let cmd = parse("cp backup.txt", false).unwrap();
1097 assert_eq!(cmd, ParsedCommand::Cp("backup.txt".to_owned()));
1098 }
1099
1100 #[test]
1101 fn parse_git_valid() {
1102 let cmd = parse("git status --short", false).unwrap();
1103 assert_eq!(cmd, ParsedCommand::Git("status --short".to_owned()));
1104 }
1105
1106 #[test]
1107 fn parse_git_no_subcommand_is_error() {
1108 let err = parse("git", false).unwrap_err();
1109 assert!(matches!(err, ParseError::MissingArgument(_)));
1110 }
1111
1112 #[test]
1113 fn parse_set_valid() {
1114 let cmd = parse("set git_status_enabled true", false).unwrap();
1115 assert_eq!(
1116 cmd,
1117 ParsedCommand::Set {
1118 key: "git_status_enabled".to_owned(),
1119 value: "true".to_owned(),
1120 }
1121 );
1122 }
1123
1124 #[test]
1125 fn parse_set_no_value_is_error() {
1126 let err = parse("set key_only", false).unwrap_err();
1127 assert!(matches!(err, ParseError::MissingArgument(_)));
1128 }
1129
1130 #[test]
1131 fn parse_sort_key_only() {
1132 use crate::app::sort::SortBy;
1133 assert_eq!(
1134 parse("sort size", false).unwrap(),
1135 ParsedCommand::Sort {
1136 by: Some(SortBy::Size),
1137 reverse: false
1138 }
1139 );
1140 }
1141
1142 #[test]
1143 fn parse_sort_key_and_reverse_in_either_word_order() {
1144 use crate::app::sort::SortBy;
1145 let expected = ParsedCommand::Sort {
1146 by: Some(SortBy::Modified),
1147 reverse: true,
1148 };
1149 assert_eq!(parse("sort modified reverse", false).unwrap(), expected);
1150 assert_eq!(parse("sort reverse modified", false).unwrap(), expected);
1151 }
1152
1153 #[test]
1154 fn parse_sort_reverse_alone_keeps_the_current_key() {
1155 assert_eq!(
1156 parse("sort reverse", false).unwrap(),
1157 ParsedCommand::Sort {
1158 by: None,
1159 reverse: true
1160 }
1161 );
1162 }
1163
1164 #[test]
1165 fn parse_sort_with_no_argument_is_error() {
1166 let err = parse("sort", false).unwrap_err();
1167 assert!(matches!(err, ParseError::MissingArgument(_)));
1168 }
1169
1170 #[test]
1171 fn parse_sort_with_an_unknown_key_is_error() {
1172 let err = parse("sort created", false).unwrap_err();
1175 assert!(matches!(err, ParseError::InvalidArgument(_)), "{err:?}");
1176 }
1177
1178 #[test]
1179 fn parse_empty_is_error() {
1180 let err = parse("", false).unwrap_err();
1181 assert!(matches!(err, ParseError::Empty));
1182 }
1183
1184 #[test]
1185 fn parse_unknown_verb_is_error() {
1186 let err = parse("zap foo", false).unwrap_err();
1187 assert!(matches!(err, ParseError::UnknownVerb(_)));
1188 }
1189
1190 #[test]
1191 fn parse_plugin_valid() {
1192 let cmd = parse("plugin my_action", false).unwrap();
1193 assert_eq!(
1194 cmd,
1195 ParsedCommand::Plugin {
1196 name: "my_action".to_owned(),
1197 arg: "".to_owned(),
1198 }
1199 );
1200 }
1201
1202 #[test]
1203 fn parse_plugin_with_args() {
1204 let cmd = parse("plugin log_note hello world", false).unwrap();
1205 assert_eq!(
1206 cmd,
1207 ParsedCommand::Plugin {
1208 name: "log_note".to_owned(),
1209 arg: "hello world".to_owned(),
1210 }
1211 );
1212 }
1213
1214 #[test]
1215 fn parse_plugin_no_action_is_error() {
1216 let err = parse("plugin", false).unwrap_err();
1217 assert!(matches!(err, ParseError::MissingArgument(_)));
1218 }
1219
1220 #[test]
1221 fn parse_shell_command() {
1222 let cmd = parse("ls -la", true).unwrap();
1223 assert_eq!(cmd, ParsedCommand::Shell("ls -la".to_owned()));
1224 }
1225
1226 #[test]
1227 fn parse_shell_empty_is_error() {
1228 let err = parse("", true).unwrap_err();
1229 assert!(matches!(err, ParseError::MissingArgument(_)));
1230 }
1231
1232 #[test]
1235 fn verb_completion_prefix_mk() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let candidates = completions("mk", dir.path(), false);
1238 assert!(
1239 candidates.contains(&"mkdir ".to_owned()),
1240 "expected 'mkdir ' in candidates: {candidates:?}"
1241 );
1242 }
1243
1244 #[test]
1245 fn verb_completion_empty_returns_all_verbs() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let candidates = completions("", dir.path(), false);
1248 let mut got: Vec<String> = candidates.iter().map(|c| c.trim().to_owned()).collect();
1252 got.sort();
1253 assert_eq!(
1254 got,
1255 vec![
1256 "bookmark", "config", "cp", "git", "jump", "mkdir", "mv", "plugin", "rename",
1257 "set", "sort", "term", "touch", "unmark",
1258 ]
1259 );
1260 }
1261
1262 #[test]
1263 fn plugin_action_completion() {
1264 let dir = tempfile::tempdir().unwrap();
1265 let candidates = completions_with_sources(
1266 "plugin ",
1267 dir.path(),
1268 false,
1269 &CompletionSources {
1270 plugin_actions: &["log_note", "bookmark_add"],
1271 bookmarks: &[],
1272 },
1273 );
1274 assert_eq!(candidates, vec!["bookmark_add", "log_note"]);
1275 }
1276
1277 #[test]
1278 fn verb_completion_no_match_returns_empty() {
1279 let dir = tempfile::tempdir().unwrap();
1280 let candidates = completions("zzz", dir.path(), false);
1281 assert!(candidates.is_empty());
1282 }
1283
1284 #[test]
1285 fn path_completion_for_mv() {
1286 use std::fs;
1287 let dir = tempfile::tempdir().unwrap();
1288 fs::write(dir.path().join("alpha.txt"), b"").unwrap();
1289 fs::write(dir.path().join("beta.txt"), b"").unwrap();
1290
1291 let candidates = completions("mv al", dir.path(), false);
1292 assert!(
1293 candidates.contains(&"alpha.txt".to_owned()),
1294 "expected 'alpha.txt' in candidates: {candidates:?}"
1295 );
1296 assert!(
1297 !candidates.contains(&"beta.txt".to_owned()),
1298 "beta.txt should not match prefix 'al'"
1299 );
1300 }
1301
1302 #[test]
1303 fn no_path_completion_for_mkdir() {
1304 let dir = tempfile::tempdir().unwrap();
1305 let candidates = completions("mkdir so", dir.path(), false);
1306 assert!(
1307 candidates.is_empty(),
1308 "path completion should not apply to mkdir"
1309 );
1310 }
1311
1312 #[test]
1315 fn history_push_and_retrieve() {
1316 let mut h = CommandHistory::new();
1317 h.push("mkdir foo".to_owned());
1318 h.push("touch bar".to_owned());
1319 assert_eq!(h.len(), 2);
1320 assert_eq!(h.prev(0), Some("touch bar"));
1321 assert_eq!(h.prev(1), Some("mkdir foo"));
1322 }
1323
1324 #[test]
1325 fn history_deduplicates_consecutive_identical() {
1326 let mut h = CommandHistory::new();
1327 h.push("mkdir foo".to_owned());
1328 h.push("mkdir foo".to_owned());
1329 assert_eq!(h.len(), 1);
1330 }
1331
1332 #[test]
1333 fn history_allows_non_consecutive_duplicate() {
1334 let mut h = CommandHistory::new();
1335 h.push("mkdir foo".to_owned());
1336 h.push("touch bar".to_owned());
1337 h.push("mkdir foo".to_owned());
1338 assert_eq!(h.len(), 3);
1339 }
1340
1341 #[test]
1342 fn history_evicts_oldest_at_capacity() {
1343 let mut h = CommandHistory::new();
1344 for i in 0..HISTORY_CAPACITY + 5 {
1345 h.push(format!("cmd{i}"));
1346 }
1347 assert_eq!(h.len(), HISTORY_CAPACITY);
1348 assert_eq!(
1350 h.prev(0),
1351 Some(format!("cmd{}", HISTORY_CAPACITY + 4).as_str())
1352 );
1353 }
1354
1355 #[test]
1356 fn history_persist_and_reload() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let path = dir.path().join("history.txt");
1359
1360 let mut h = CommandHistory::with_path(path.clone());
1361 h.push("mkdir foo".to_owned());
1362 h.push("touch bar".to_owned());
1363
1364 let h2 = CommandHistory::with_path(path);
1366 assert_eq!(h2.len(), 2);
1367 assert_eq!(h2.prev(0), Some("touch bar"));
1368 }
1369
1370 fn tab_cycle(buffer: &str, cwd: &Path, times: usize) -> Vec<String> {
1375 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1376 let mut buf = buffer.to_owned();
1377 let mut cursor = buf.len();
1378 let mut hist_idx = None;
1379 let mut tab = TabState::new();
1380 let h = CommandHistory::new();
1381 let key = KeyEvent {
1382 code: KeyCode::Tab,
1383 modifiers: KeyModifiers::NONE,
1384 kind: KeyEventKind::Press,
1385 state: KeyEventState::NONE,
1386 };
1387
1388 (0..times)
1389 .map(|_| {
1390 feed(
1391 key,
1392 &mut buf,
1393 &mut cursor,
1394 &mut hist_idx,
1395 &mut tab,
1396 &h,
1397 cwd,
1398 false,
1399 );
1400 buf.clone()
1401 })
1402 .collect()
1403 }
1404
1405 #[test]
1410 fn tab_cycles_through_verb_completions_and_wraps() {
1411 let dir = tempfile::tempdir().unwrap();
1412 assert_eq!(
1413 tab_cycle("m", dir.path(), 3),
1414 vec!["mkdir ", "mv ", "mkdir "],
1415 "Tab must step through every verb sharing the prefix, then wrap"
1416 );
1417 }
1418
1419 #[test]
1420 fn tab_cycles_through_path_completions() {
1421 let dir = tempfile::tempdir().unwrap();
1422 fs::write(dir.path().join("alpha.txt"), b"").unwrap();
1423 fs::write(dir.path().join("alpine.txt"), b"").unwrap();
1424
1425 assert_eq!(
1426 tab_cycle("mv al", dir.path(), 3),
1427 vec!["mv alpha.txt", "mv alpine.txt", "mv alpha.txt"],
1428 "a destination with two matches must offer both"
1429 );
1430 }
1431
1432 #[test]
1435 fn editing_restarts_the_completion_cycle() {
1436 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1437 let dir = tempfile::tempdir().unwrap();
1438 let mut buf = "m".to_owned();
1439 let mut cursor = buf.len();
1440 let mut hist_idx = None;
1441 let mut tab = TabState::new();
1442 let h = CommandHistory::new();
1443 let press = |code| KeyEvent {
1444 code,
1445 modifiers: KeyModifiers::NONE,
1446 kind: KeyEventKind::Press,
1447 state: KeyEventState::NONE,
1448 };
1449
1450 let mut send = |code, buf: &mut String, cursor: &mut usize| {
1451 feed(
1452 press(code),
1453 buf,
1454 cursor,
1455 &mut hist_idx,
1456 &mut tab,
1457 &h,
1458 dir.path(),
1459 false,
1460 );
1461 };
1462
1463 send(KeyCode::Tab, &mut buf, &mut cursor);
1464 assert_eq!(buf, "mkdir ");
1465 send(KeyCode::Backspace, &mut buf, &mut cursor);
1467 send(KeyCode::Tab, &mut buf, &mut cursor);
1468 assert_eq!(
1469 buf, "mkdir ",
1470 "the new cycle completes the edited text, not the old prefix"
1471 );
1472 }
1473
1474 #[test]
1477 fn feed_char_appends_to_buffer() {
1478 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1479 let dir = tempfile::tempdir().unwrap();
1480 let mut buf = String::new();
1481 let mut cursor = 0usize;
1482 let mut hist_idx = None;
1483 let mut tab = TabState::new();
1484 let h = CommandHistory::new();
1485
1486 let key = KeyEvent {
1487 code: KeyCode::Char('m'),
1488 modifiers: KeyModifiers::NONE,
1489 kind: KeyEventKind::Press,
1490 state: KeyEventState::NONE,
1491 };
1492 let result = feed(
1493 key,
1494 &mut buf,
1495 &mut cursor,
1496 &mut hist_idx,
1497 &mut tab,
1498 &h,
1499 dir.path(),
1500 false,
1501 );
1502 assert_eq!(result, FeedResult::Updated);
1503 assert_eq!(buf, "m");
1504 assert_eq!(cursor, 1);
1505 }
1506
1507 #[test]
1510 fn feed_keeps_altgr_backslashes_of_a_pasted_path() {
1511 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1512 let dir = tempfile::tempdir().unwrap();
1513 let mut buf = String::new();
1514 let mut cursor = 0usize;
1515 let mut hist_idx = None;
1516 let mut tab = TabState::new();
1517 let h = CommandHistory::new();
1518
1519 let pasted = r"C:\Users\me\file.txt";
1520 for ch in pasted.chars() {
1521 let modifiers = match ch {
1522 '\\' => KeyModifiers::CONTROL | KeyModifiers::ALT,
1523 ':' | 'C' | 'U' => KeyModifiers::SHIFT,
1524 _ => KeyModifiers::NONE,
1525 };
1526 let key = KeyEvent {
1527 code: KeyCode::Char(ch),
1528 modifiers,
1529 kind: KeyEventKind::Press,
1530 state: KeyEventState::NONE,
1531 };
1532 feed(
1533 key,
1534 &mut buf,
1535 &mut cursor,
1536 &mut hist_idx,
1537 &mut tab,
1538 &h,
1539 dir.path(),
1540 false,
1541 );
1542 }
1543 assert_eq!(buf, pasted);
1544 assert_eq!(cursor, pasted.len());
1545 }
1546
1547 #[test]
1548 fn feed_bare_ctrl_char_is_not_text() {
1549 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1550 let dir = tempfile::tempdir().unwrap();
1551 let mut buf = String::new();
1552 let mut cursor = 0usize;
1553 let mut hist_idx = None;
1554 let mut tab = TabState::new();
1555 let h = CommandHistory::new();
1556
1557 let key = KeyEvent {
1558 code: KeyCode::Char('x'),
1559 modifiers: KeyModifiers::CONTROL,
1560 kind: KeyEventKind::Press,
1561 state: KeyEventState::NONE,
1562 };
1563 feed(
1564 key,
1565 &mut buf,
1566 &mut cursor,
1567 &mut hist_idx,
1568 &mut tab,
1569 &h,
1570 dir.path(),
1571 false,
1572 );
1573 assert!(buf.is_empty());
1574 }
1575
1576 #[test]
1577 fn feed_enter_submits_buffer() {
1578 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1579 let dir = tempfile::tempdir().unwrap();
1580 let mut buf = "mkdir foo".to_owned();
1581 let mut cursor = buf.len();
1582 let mut hist_idx = None;
1583 let mut tab = TabState::new();
1584 let h = CommandHistory::new();
1585
1586 let key = KeyEvent {
1587 code: KeyCode::Enter,
1588 modifiers: KeyModifiers::NONE,
1589 kind: KeyEventKind::Press,
1590 state: KeyEventState::NONE,
1591 };
1592 let result = feed(
1593 key,
1594 &mut buf,
1595 &mut cursor,
1596 &mut hist_idx,
1597 &mut tab,
1598 &h,
1599 dir.path(),
1600 false,
1601 );
1602 assert_eq!(result, FeedResult::Submit("mkdir foo".to_owned()));
1603 }
1604
1605 #[test]
1606 fn feed_esc_cancels() {
1607 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1608 let dir = tempfile::tempdir().unwrap();
1609 let mut buf = "mkdir foo".to_owned();
1610 let mut cursor = buf.len();
1611 let mut hist_idx = None;
1612 let mut tab = TabState::new();
1613 let h = CommandHistory::new();
1614
1615 let key = KeyEvent {
1616 code: KeyCode::Esc,
1617 modifiers: KeyModifiers::NONE,
1618 kind: KeyEventKind::Press,
1619 state: KeyEventState::NONE,
1620 };
1621 let result = feed(
1622 key,
1623 &mut buf,
1624 &mut cursor,
1625 &mut hist_idx,
1626 &mut tab,
1627 &h,
1628 dir.path(),
1629 false,
1630 );
1631 assert_eq!(result, FeedResult::Cancel);
1632 }
1633
1634 #[test]
1635 fn feed_backspace_removes_last_char() {
1636 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1637 let dir = tempfile::tempdir().unwrap();
1638 let mut buf = "abc".to_owned();
1639 let mut cursor = 3usize;
1640 let mut hist_idx = None;
1641 let mut tab = TabState::new();
1642 let h = CommandHistory::new();
1643
1644 let key = KeyEvent {
1645 code: KeyCode::Backspace,
1646 modifiers: KeyModifiers::NONE,
1647 kind: KeyEventKind::Press,
1648 state: KeyEventState::NONE,
1649 };
1650 feed(
1651 key,
1652 &mut buf,
1653 &mut cursor,
1654 &mut hist_idx,
1655 &mut tab,
1656 &h,
1657 dir.path(),
1658 false,
1659 );
1660 assert_eq!(buf, "ab");
1661 assert_eq!(cursor, 2);
1662 }
1663}