Skip to main content

trail/input/
command_parser.rs

1//! Command Mode grammar: parsing, history, completion, and validation.
2//!
3//! Handles the `:` and `!`-prefixed command grammar including `:mkdir`,
4//! `:touch`, `:rename`, `:mv`, `:cp`, `:git`, `:set`, `:term`, and `!<shell>`.
5//!
6//! # Grammar
7//!
8//! ```text
9//! command ::= ":" verb (" " arg)*
10//!           | "!" shell_string
11//!
12//! verb    ::= "mkdir" | "touch" | "rename" | "mv" | "cp" | "git" | "set"
13//!           | "sort" | "bookmark" | "jump" | "unmark" | "config" | "plugin"
14//!           | "term"
15//! ```
16//!
17//! Parsing only validates shape; what a command does is `actions::mod`'s
18//! business. `:git` runs `git` through the configured shell, and `:set` goes
19//! through `TrailConfig::set_value`.
20
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26// ── Parsed command ─────────────────────────────────────────────────────────────
27
28/// A successfully parsed, validated Command Mode input.
29///
30/// Each variant carries exactly the arguments it needs; the parser rejects
31/// inputs that don't match the expected arity or content constraints.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ParsedCommand {
34    /// Create a directory with the given name inside the current directory.
35    Mkdir(String),
36    /// Create an empty file with the given name inside the current directory.
37    Touch(String),
38    /// Rename the currently selected entry to `new_name`.
39    Rename(String),
40    /// Move the selected entry to `dest` (relative or absolute path).
41    Mv(String),
42    /// Copy the selected entry to `dest` (relative or absolute path).
43    Cp(String),
44    /// Move every entry of `cwd` matching `pattern` into the directory `dest`.
45    MvMatching {
46        /// A name with `*` or `?` in it, matched against the current directory.
47        pattern: String,
48        /// Destination, which has to be an existing directory: several files
49        /// cannot be moved onto one path.
50        dest: String,
51    },
52    /// Copy every entry of `cwd` matching `pattern` into the directory `dest`.
53    CpMatching {
54        /// A name with `*` or `?` in it, matched against the current directory.
55        pattern: String,
56        /// Destination, which has to be an existing directory.
57        dest: String,
58    },
59    /// Run a git subcommand string (e.g. `"init"`, `"status --short"`) as
60    /// `git <subcommand>` through `[general] shell`, holding the screen
61    /// afterwards per `[general] shell_pause`.
62    Git(String),
63    /// Set a runtime config key to a value, through `TrailConfig::set_value`.
64    Set { key: String, value: String },
65    /// Re-order the active tab's listing: `:sort [key] [reverse]`.
66    ///
67    /// `by` is `None` when the command named only `reverse`, which flips the
68    /// order already in use rather than choosing a new one.
69    Sort {
70        /// The key to order by, or `None` to keep the current one.
71        by: Option<crate::app::sort::SortBy>,
72        /// Whether to flip the resulting order.
73        reverse: bool,
74    },
75    /// Execute an arbitrary shell command string.
76    Shell(String),
77    /// Add a bookmark: `:bookmark <name>` saves `cwd` under `name`.
78    /// If `name` is empty the current directory base-name is used.
79    Bookmark(String),
80    /// Jump to a previously saved bookmark: `:jump <name>`.
81    Jump(String),
82    /// Run a custom plugin action: `:plugin <name> [arg]`.
83    Plugin { name: String, arg: String },
84    /// Drive the terminal panel: `:term`, `:term new [profile]`,
85    /// `:term close`, `:term <n>`, `:term max`.
86    Term(crate::terminal::TermCommand),
87    /// Unmark every marked entry: `:unmark`.
88    Unmark,
89    /// Save this session's `:set` changes into the config file:
90    /// `:config write`.
91    ConfigWrite,
92}
93
94// ── Parse error ────────────────────────────────────────────────────────────────
95
96/// Validation errors produced during command parsing.
97#[derive(Debug, Error, Clone, PartialEq, Eq)]
98pub enum ParseError {
99    /// An unrecognised command verb was entered.
100    #[error("unknown command '{0}' — try :mkdir, :touch, :rename, :mv, :cp, :git, :set, :sort, :bookmark, :jump, :unmark, :config, :plugin, :term")]
101    UnknownVerb(String),
102    /// A required argument was not provided.
103    #[error("{0} requires an argument")]
104    MissingArgument(String),
105    /// The argument failed a content constraint (e.g. `/` in a rename).
106    #[error("{0}")]
107    InvalidArgument(String),
108    /// The command buffer is empty.
109    #[error("empty command")]
110    Empty,
111}
112
113// ── Parser ────────────────────────────────────────────────────────────────────
114
115/// Parse `buffer` (the text typed after `:` or `!`) into a [`ParsedCommand`].
116///
117/// The buffer should **not** include the leading `:` or `!` sentinel — the
118/// caller strips it before calling here. The `!` prefix is preserved as a
119/// flag argument to distinguish shell execution from `:` commands.
120///
121/// # Errors
122///
123/// Returns [`ParseError`] if the buffer is empty, contains an unknown verb,
124/// is missing a required argument, or fails content validation.
125pub 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    // Split into verb + rest. The rest after the first word is the argument(s).
140    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            // Directory names cannot be empty; reject embedded path separators
151            // to prevent accidental deep creation.
152            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            // Expect: set <key> <value>
216            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            // `:sort`, `:sort <key>`, `:sort reverse`, `:sort <key> reverse` —
240            // in any order, because "sort reverse size" is the same request as
241            // "sort size reverse" and refusing it teaches nothing.
242            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            // :bookmark [name] — name is optional; empty string means
266            // "use the cwd base-name" (resolved at execution time).
267            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            // :jump <name> — name is required.
278            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            // :plugin <action_name> [arg] — action name is required.
287            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
329/// Parses the arguments of `:term`.
330fn 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
356/// Splits the argument of `:mv`/`:cp` into a wildcard pattern and a destination,
357/// or `None` when it is an ordinary one-argument destination.
358///
359/// The first whitespace-separated token decides: a `*` or a `?` in it means the
360/// user is naming several files, and everything after it is the destination.
361/// Nothing else is treated as two arguments, which is what keeps a destination
362/// containing a space (`:mv my folder`) working exactly as before -- `*` and `?`
363/// cannot appear in a Windows filename at all.
364///
365/// # Errors
366///
367/// Returns [`ParseError::MissingArgument`] for a pattern with no destination
368/// after it: there is no sensible default, and guessing `cwd` would move files
369/// onto themselves.
370fn 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// ── Completion ────────────────────────────────────────────────────────────────
388
389/// Returns a list of completion candidates for the current command `buffer`.
390///
391/// Completions are provided for:
392/// - **Verb completion**: when the buffer is a prefix of a known verb (no
393///   space typed yet), return the full verb with a trailing space.
394/// - **Path completion**: for `:mv` and `:cp`, complete the destination
395///   argument against filesystem entries. A destination that is a whole route
396///   (`nested\`, `..\dst\re`, `C:\Users\me\Desk`) is completed against the
397///   directory the route names, not against `cwd`. Directory candidates carry
398///   a trailing separator, so a route can be walked one `Tab` at a time.
399///
400/// The returned `Vec` is empty when no completions apply. The caller should
401/// cycle through candidates on repeated `Tab` presses.
402///
403/// `is_shell`: whether the buffer came from a `!`-prefixed input (no verb
404/// completion applies — the shell handles its own completion).
405#[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/// What completion can offer beyond verbs and the filesystem: names that live
411/// in Trail's own state rather than on disk.
412#[derive(Debug, Default, Clone, Copy)]
413pub struct CompletionSources<'a> {
414    /// Registered plugin action names, for `:plugin`.
415    pub plugin_actions: &'a [&'a str],
416    /// Bookmarks as `(name, path)`, for an `@name` destination of `:mv`/`:cp`.
417    pub bookmarks: &'a [(String, PathBuf)],
418}
419
420/// Same as [`completions`], but with plugin action names and bookmarks to
421/// offer as well.
422pub 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(); // shell completion not handled here
430    }
431
432    let trimmed = buffer.trim_start();
433
434    // If there's no space yet, complete the verb.
435    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    // Verb is typed — check sub-completions.
449    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    // Complete `partial` against entries in cwd.
474    path_completions(partial, cwd)
475}
476
477/// Completes an `@name` destination: the bookmark names first, then — once a
478/// separator follows the name — the route inside the bookmarked directory.
479///
480/// `after_at` is what follows the `@`. Candidates are whole tokens, `@` and
481/// all, because [`apply_completion`] replaces the whole token.
482fn 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            // `route` still starts with its separator, which is what the
499            // bookmark name is joined back on with.
500            let (sep, inner) = route.split_at(1);
501            // With no route of its own, `path_completions` ends a directory
502            // with the platform separator; the one typed after the name is
503            // the one to keep, as it is everywhere else.
504            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
518/// Returns filesystem-based completion candidates matching `partial`.
519///
520/// `partial` is the destination typed so far. It may be a bare name (`no`), a
521/// route into a subdirectory (`nested\re`, `..\dst\`) or an absolute path
522/// (`C:\Users\me\Desk`). Everything up to the last separator names the
523/// directory to list; what follows is the prefix to match against its entries
524/// (case-sensitive). A route that names no directory is completed against
525/// `cwd`, as before.
526///
527/// Each candidate is the whole token to put back on the command line, route
528/// included, because [`apply_completion`] replaces the entire space-delimited
529/// token. Directories carry a trailing separator so a route can be walked one
530/// `Tab` at a time, and the separator the user already typed is the one used —
531/// a route typed with `\` stays spelled with `\`.
532fn path_completions(partial: &str, cwd: &Path) -> Vec<String> {
533    // `std::path::is_separator` is the platform's own answer: `/` and `\` on
534    // Windows, `/` alone on Unix, where a backslash is an ordinary character in
535    // a file name and must not split a route.
536    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        // `join` is correct for every shape a route can take here, including a
543        // Windows path that is rooted but has no drive (`\Users`), which
544        // replaces everything but `cwd`'s drive letter.
545        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
579// ── Command history ───────────────────────────────────────────────────────────
580
581/// Maximum number of entries kept in the command history ring buffer.
582const HISTORY_CAPACITY: usize = 100;
583
584/// A ring buffer of past commands, persisted to a small cache file.
585///
586/// The buffer stores the most recent `HISTORY_CAPACITY` unique commands.
587/// Duplicate consecutive entries are deduplicated. History is persisted in a
588/// newline-delimited text file: one command per line, most-recent last.
589#[derive(Debug, Default)]
590pub struct CommandHistory {
591    /// Entries, oldest first, most-recent last.
592    entries: Vec<String>,
593    /// Path to the persistence file; `None` means in-memory only.
594    path: Option<PathBuf>,
595}
596
597impl CommandHistory {
598    /// Creates a new, in-memory-only history buffer.
599    pub fn new() -> Self {
600        CommandHistory {
601            entries: Vec::new(),
602            path: None,
603        }
604    }
605
606    /// Creates a history buffer backed by `path`.
607    ///
608    /// Existing entries are loaded immediately. If the file does not exist yet
609    /// the history starts empty (it will be created on the first `push`). If
610    /// the file exists but cannot be read, the history starts empty and will
611    /// overwrite the file on the next `push`.
612    // clippy: dead_code — called from command_parser_tests.rs and will be
613    // wired into AppState::new() once the config dir path is resolved (Phase 7).
614    #[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    /// Appends `command` to the history, deduplicating consecutive identical
624    /// entries and evicting the oldest entry when the ring buffer is full.
625    ///
626    /// Persists to disk if a path was provided. Errors are silently logged at
627    /// `debug` level — history persistence is best-effort, not load-bearing.
628    pub fn push(&mut self, command: String) {
629        if command.is_empty() {
630            return;
631        }
632        // Deduplicate consecutive identical entries.
633        if self.entries.last().map(|s| s.as_str()) == Some(command.as_str()) {
634            return;
635        }
636        // Evict oldest if at capacity.
637        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    /// Returns the number of history entries.
650    pub fn len(&self) -> usize {
651        self.entries.len()
652    }
653
654    /// Returns `true` if there are no history entries.
655    pub fn is_empty(&self) -> bool {
656        self.entries.is_empty()
657    }
658
659    /// Returns the entry at position `index` (0 = oldest, len-1 = newest).
660    ///
661    /// Returns `None` if `index` is out of range.
662    pub fn get(&self, index: usize) -> Option<&str> {
663        self.entries.get(index).map(String::as_str)
664    }
665
666    /// Returns the entry at `offset` positions from the most-recent end.
667    ///
668    /// `prev(0)` → most-recent; `prev(1)` → second-most-recent; etc.
669    /// Returns `None` when `offset` exceeds the number of entries.
670    // clippy: dead_code — used in command_parser_tests.rs integration tests.
671    #[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/// Reads a history file. Each non-empty line is one entry.
679// clippy: dead_code — called by with_path which is used in tests and
680// will be wired into AppState::new() in Phase 7.
681#[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    // Trim to capacity in case the file was hand-edited.
690    let start = entries.len().saturating_sub(HISTORY_CAPACITY);
691    Some(entries[start..].to_vec())
692}
693
694/// Writes all history entries to `path`, one per line.
695fn save_history(path: &Path, entries: &[String]) -> std::io::Result<()> {
696    // Ensure the parent directory exists.
697    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// ── Command mode feed (key-level entry point) ─────────────────────────────────
708
709/// The result of feeding one keystroke to Command Mode.
710///
711/// The caller in `input/mod.rs` dispatches on this to decide which `Action`
712/// to queue, if any.
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub enum FeedResult {
715    /// The buffer was updated but no action should fire yet.
716    Updated,
717    /// The user pressed `Enter`; `buffer` is the completed input.
718    Submit(String),
719    /// The user pressed `Esc`; exit Command Mode without executing.
720    Cancel,
721    /// The user pressed `Tab`; `candidates` is the list of completions and
722    /// `index` is which one was cycled to this press.
723    Completion {
724        candidates: Vec<String>,
725        index: usize,
726    },
727}
728
729/// Feeds a single keystroke into Command Mode state.
730///
731/// `buffer`: the current typed text (after `:` or `!`).
732/// `cursor`: byte offset of the insertion point.
733/// `history_index`: current history scroll position (`None` = live buffer).
734/// `tab_state`: tracks the current completion cycle.
735/// `history`: the command history.
736/// `cwd`: current directory, used for path completions.
737/// `is_shell`: whether the leading sentinel was `!` (not `:`).
738///
739/// Mutates `buffer`, `cursor`, and `history_index` in-place. Returns a
740/// [`FeedResult`] describing what the caller should do next.
741#[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/// Same as [`feed`], but completion also offers plugin action names and
767/// bookmarks.
768#[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            // Collect the buffer before returning.
787            FeedResult::Submit(buffer.clone())
788        }
789
790        KeyCode::Backspace => {
791            // Delete the character immediately before the cursor.
792            if *cursor > 0 {
793                // Walk back one UTF-8 character boundary.
794                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            // Delete the character immediately before the cursor.
804            if *cursor > 0 {
805                // Walk back one UTF-8 character boundary.
806                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            // Delete the character at the cursor (forward delete).
816            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            // Moving the cursor abandons the completion cycle: the next `Tab`
829            // should complete where the cursor is now, not where it started.
830            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            // Scroll back through history.
844            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            // Scroll forward through history.
864            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                        // Past the end of history — restore blank buffer.
876                        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            // Candidates come from the text that opened the cycle, not from the
888            // buffer — the previous `Tab` has already overwritten that with a
889            // candidate, and completing a completion is how the cycle used to
890            // get stuck on its first answer.
891            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            // Cycle to the next candidate.
897            let idx = tab_state.advance(candidates.len());
898            // Apply the completion: replace the completed token with the candidate.
899            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        // Only a bare Ctrl is a chord here. AltGr arrives as Ctrl+Alt on
919        // Windows and is how `\` is typed (and pasted) on many layouts.
920        KeyCode::Char(ch)
921            if crate::input::is_text_modifiers(key.modifiers)
922                || key.modifiers.contains(KeyModifiers::ALT) =>
923        {
924            // Insert the character at the cursor position.
925            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/// Tracks the current tab-completion cycle.
937///
938/// Resets when the buffer changes (any non-Tab keystroke).
939#[derive(Debug, Default)]
940pub struct TabState {
941    /// Current position in the candidate list (cycling).
942    index: Option<usize>,
943    /// The buffer text the cycle is completing, captured on the first `Tab`.
944    ///
945    /// Applying a completion overwrites the buffer, so the text the user
946    /// actually typed is gone by the second `Tab`. Without it, candidates were
947    /// recomputed from the completed buffer: `:m` + `Tab` gives `mkdir `, which
948    /// contains a space, so the next `Tab` took the path-completion branch,
949    /// found `mkdir` is not `mv`/`cp`, and returned nothing — `mv` could never
950    /// be reached.
951    prefix: Option<String>,
952}
953
954impl TabState {
955    /// Creates a new, idle `TabState`.
956    pub fn new() -> Self {
957        TabState {
958            index: None,
959            prefix: None,
960        }
961    }
962
963    /// Resets the completion cycle (called on any non-Tab keystroke).
964    pub fn reset(&mut self) {
965        self.index = None;
966        self.prefix = None;
967    }
968
969    /// Returns the text this cycle completes, starting a cycle at `buffer` when
970    /// none is running.
971    ///
972    /// Every `Tab` in the same cycle therefore sees the same candidate list, in
973    /// the same order, which is what makes [`TabState::advance`] step through it
974    /// rather than re-deciding it.
975    pub fn cycle_prefix(&mut self, buffer: &str) -> String {
976        self.prefix.get_or_insert_with(|| buffer.to_owned()).clone()
977    }
978
979    /// Advances to the next completion and returns the new index.
980    ///
981    /// Wraps around when `len` is exceeded.
982    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
992/// Rewrites `buffer` as `prefix` with its last token replaced by `candidate`.
993///
994/// `prefix` is what the user typed before the cycle started, not the current
995/// buffer: the buffer already holds the previous candidate, and replacing that
996/// one's last token would compound completions instead of offering the next.
997///
998/// For verb completion (no space in `prefix`) the candidate is the whole line.
999/// For path completion everything up to the last space is kept.
1000fn 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; // Shell completions not handled here.
1009    }
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
1017// ── UTF-8 cursor helpers ─────────────────────────────────────────────────────
1018
1019/// Returns the byte offset of the start of the character immediately before
1020/// `cursor` in `s`. Panics if `cursor == 0`; callers must guard.
1021fn 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
1029/// Returns the byte offset of the start of the next character after `cursor`.
1030fn 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// ── Unit tests ────────────────────────────────────────────────────────────────
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043
1044    // ── parse ──────────────────────────────────────────────────────────────────
1045
1046    #[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        // `created` is the one a user is most likely to reach for; it is not a
1173        // key because the platform will not reliably report a creation time.
1174        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    // ── completions ────────────────────────────────────────────────────────────
1233
1234    #[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        // A count alone says nothing about *which* verb went missing, so the
1249        // set is asserted: a verb added to the parser and forgotten here is the
1250        // failure this catches.
1251        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    // ── CommandHistory ─────────────────────────────────────────────────────────
1313
1314    #[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        // The oldest entries should be gone; the most recent should survive.
1349        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        // Reload from the same path.
1365        let h2 = CommandHistory::with_path(path);
1366        assert_eq!(h2.len(), 2);
1367        assert_eq!(h2.prev(0), Some("touch bar"));
1368    }
1369
1370    // ── feed (tab completion) ──────────────────────────────────────────────────
1371
1372    /// Presses `Tab` `times` times against `buffer` and returns what the buffer
1373    /// became, driving the same `TabState` throughout as a real session does.
1374    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    /// The bug this guards: applying a completion overwrote the buffer, and the
1406    /// next `Tab` recomputed candidates from *that*. `:m` completed to `mkdir `,
1407    /// which contains a space, so the second `Tab` looked for path completions
1408    /// for a verb that takes none and found nothing — `mv` was unreachable.
1409    #[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    /// Typing anything ends the cycle, so the next `Tab` completes what is on
1433    /// the line now rather than continuing an abandoned list.
1434    #[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        // Backspace leaves "mkdir", which is a complete verb on its own.
1466        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    // ── feed (cursor movement) ─────────────────────────────────────────────────
1475
1476    #[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    /// A pasted Windows path arrives one key at a time, and on layouts where
1508    /// `\` needs AltGr each backslash carries Ctrl+Alt. None may be dropped.
1509    #[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}