//! Editable, shell-free `:` command input over the shared storage command contract. use std::{error::Error, fmt}; use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from}; use crate::action::{ACTIONS, Action}; const HISTORY_LIMIT: usize = 100; const ROOT_COMMANDS: &[&str] = &[ "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", "import-kdbx", "git", "otp", "lock", "unlock", "help", "version", "quit", ]; const GIT_COMMANDS: &[&str] = &[ "init", "status", "log", "diff", "add", "commit", "remote", "config", "fetch", "pull", "push", "sync", "resolve-local", "resolve-remote", ]; const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"]; const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"]; const HELP_TOPICS: &[&str] = &[ "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", "import-kdbx", "git", "otp", ]; /// Auditable mapping from every milestone-01 command family to the TUI command surface. pub const COMMAND_COVERAGE: &[CommandCoverage] = &[ coverage("initialize recipients", ":init GPG-ID…"), coverage("list a directory", ":list [PATH]"), coverage("show an entry", ":show [OPTIONS] [ENTRY]"), coverage("find names", ":find TERM…"), coverage("grep decrypted entries", ":grep [OPTIONS] PATTERN"), coverage("insert an entry", ":insert [OPTIONS] ENTRY"), coverage("edit an entry", ":edit ENTRY"), coverage("generate a password", ":generate [OPTIONS] ENTRY [LENGTH]"), coverage("remove entries or directories", ":remove [OPTIONS] ENTRY"), coverage( "move entries or directories", ":move [OPTIONS] SOURCE DESTINATION", ), coverage( "copy entries or directories", ":copy [OPTIONS] SOURCE DESTINATION", ), coverage("import a KeePass database", ":import-kdbx [OPTIONS] SOURCE"), coverage("initialize Git", ":git init"), coverage("show Git status", ":git status"), coverage("show Git log", ":git log [OPTIONS]"), coverage("show Git diff", ":git diff [PATH]…"), coverage("stage Git paths", ":git add PATH…"), coverage("create a Git commit", ":git commit -m MESSAGE"), coverage("manage Git remotes", ":git remote [COMMAND]"), coverage( "manage Git configuration", ":git config (--get KEY | KEY VALUE)", ), coverage("fetch Git remote", ":git fetch [REMOTE]"), coverage("pull Git remote", ":git pull [REMOTE] [BRANCH]"), coverage("push Git remote", ":git push [REMOTE] [BRANCH]"), coverage("synchronize Git remote", ":git sync [REMOTE]"), coverage("resolve Git conflicts locally", ":git resolve-local"), coverage("resolve Git conflicts remotely", ":git resolve-remote"), coverage("generate an OTP code", ":otp code [OPTIONS] ENTRY"), coverage("insert an OTP entry", ":otp insert [OPTIONS] [ENTRY]"), coverage("append OTP data", ":otp append [OPTIONS] ENTRY"), coverage("present an OTP URI", ":otp uri [OPTIONS] ENTRY"), coverage("validate an OTP URI", ":otp validate URI"), coverage("show pass-otp version", ":otp version"), coverage("show help", ":help [TOPIC]"), coverage("show version", ":version"), coverage("lock the TUI", ":lock"), coverage("unlock the TUI", ":unlock"), ]; /// Executable feature matrix for the complete upstream pass and pass-otp /// surface. `None` means the command prompt is the intentional keyboard UI; /// otherwise the action registry supplies the default hotkey and help entry. pub const TUI_COVERAGE: &[TuiCoverage] = &[ tui( "initialize recipients", "recipient form", Some(Action::Initialize), ":init GPG-ID…", ), tui( "list a directory", "password tree", Some(Action::Activate), ":list [PATH]", ), tui( "show an entry", "structured viewer", Some(Action::Activate), ":show [ENTRY]", ), tui( "copy a selected entry line", "clipboard feedback", Some(Action::Copy), ":show --clip [LINE] ENTRY", ), tui( "show a selected entry line as QR", "resizable QR popup", None, ":show --qrcode [LINE] ENTRY", ), tui( "find names", "incremental tree filter", Some(Action::Filter), ":find TERM…", ), tui( "grep decrypted entries", "decrypted result pane", Some(Action::Grep), ":grep [OPTIONS] PATTERN", ), tui( "insert an entry", "masked insert form", Some(Action::InsertEntry), ":insert [OPTIONS] ENTRY", ), tui( "edit an entry", "structured editor", Some(Action::EditEntry), ":edit ENTRY", ), tui( "generate a password", "generation form", Some(Action::GenerateEntry), ":generate [OPTIONS] ENTRY [LENGTH]", ), tui( "present a generated password", "generation presentation selector", Some(Action::GenerateEntry), ":generate (--clip | --qrcode) ENTRY [LENGTH]", ), tui( "remove entries or directories", "confirmed removal form", Some(Action::RemoveEntry), ":remove [OPTIONS] ENTRY", ), tui( "move entries or directories", "move form", Some(Action::MoveEntry), ":move [OPTIONS] SOURCE DESTINATION", ), tui( "copy entries or directories", "copy form", Some(Action::CopyEntry), ":copy [OPTIONS] SOURCE DESTINATION", ), tui( "import a KeePass database", "KDBX import form", Some(Action::ImportKdbx), ":import-kdbx [OPTIONS] SOURCE", ), tui("initialize Git", "Git dashboard", None, ":git init"), tui("show Git status", "Git dashboard", None, ":git status"), tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"), tui( "show Git diff", "zeroizing Git detail pane", None, ":git diff [PATH]…", ), tui("stage Git paths", "Git dashboard", None, ":git add PATH…"), tui( "create a Git commit", "Git dashboard", None, ":git commit -m MESSAGE", ), tui( "manage Git remotes", "Git dashboard", None, ":git remote [COMMAND]", ), tui( "manage Git configuration", "Git dashboard", None, ":git config (--get KEY | KEY VALUE)", ), tui( "fetch Git remote", "Git progress view", None, ":git fetch [REMOTE]", ), tui( "pull Git remote", "Git progress view", Some(Action::GitPull), ":git pull [REMOTE] [BRANCH]", ), tui( "push Git remote", "Git progress view", Some(Action::GitPush), ":git push [REMOTE] [BRANCH]", ), tui( "synchronize Git remote", "Git progress view", None, ":git sync [REMOTE]", ), tui( "resolve Git conflicts locally", "Git conflict view", None, ":git resolve-local", ), tui( "resolve Git conflicts remotely", "Git conflict view", None, ":git resolve-remote", ), tui( "generate an OTP code", "focused OTP field", Some(Action::OtpCode), ":otp code [OPTIONS] ENTRY", ), tui( "copy an OTP code", "clipboard feedback", Some(Action::OtpCopyCode), ":otp code --clip ENTRY", ), tui( "insert an OTP entry", "masked OTP insert form", Some(Action::OtpInsert), ":otp insert [OPTIONS] [ENTRY]", ), tui( "append OTP data", "masked OTP append form", Some(Action::OtpAppend), ":otp append [OPTIONS] ENTRY", ), tui( "present an OTP URI", "secret URI popup", Some(Action::OtpUri), ":otp uri ENTRY", ), tui( "copy an OTP URI", "clipboard feedback", Some(Action::OtpCopyUri), ":otp uri --clip ENTRY", ), tui( "present an OTP URI as QR", "resizable QR popup", Some(Action::OtpQr), ":otp uri --qrcode ENTRY", ), tui( "validate an OTP URI", "masked validation form", Some(Action::OtpValidate), ":otp validate URI", ), tui("show pass-otp version", "status line", None, ":otp version"), tui( "show help", "contextual help overlay", Some(Action::Help), ":help [TOPIC]", ), tui("show version", "status line", None, ":version"), tui("lock the TUI", "locked screen", Some(Action::Lock), ":lock"), tui( "unlock the TUI", "locked screen", Some(Action::Unlock), ":unlock", ), ]; const fn tui( operation: &'static str, element: &'static str, action: Option, command: &'static str, ) -> TuiCoverage { TuiCoverage { operation, element, action, command, } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TuiCoverage { pub operation: &'static str, pub element: &'static str, pub action: Option, pub command: &'static str, } impl TuiCoverage { pub fn default_hotkey(self) -> Option<&'static str> { let action = self.action?; ACTIONS .iter() .find(|spec| spec.action == action) .and_then(|spec| spec.bindings.first()) .map(|binding| binding.display) } } const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage { CommandCoverage { operation, command } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CommandCoverage { pub operation: &'static str, pub command: &'static str, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum CommandInvocation { Storage(CommandRequest), Display(String), Lock, Unlock, Quit, ResolveGit(ironstorage::git::GitConflictChoice), } #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommandError { message: String, } impl CommandError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl fmt::Display for CommandError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message) } } impl Error for CommandError {} #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct CommandLine { input: String, cursor: usize, history: Vec, history_position: Option, history_draft: String, completion: Option, } #[derive(Clone, Debug, Eq, PartialEq)] struct CompletionCycle { start: usize, end: usize, candidates: Vec, selected: usize, } impl CommandLine { pub fn input(&self) -> &str { &self.input } pub fn cursor(&self) -> usize { self.cursor } pub fn history(&self) -> &[String] { &self.history } pub fn display(&self) -> String { if is_secret_bearing(&self.input) { let command = tokenize(&self.input) .ok() .and_then(|tokens| { let keep = if tokens.first().is_some_and(|token| token == "otp") { 2 } else { 1 }; (!tokens.is_empty()).then(|| { format!( "{} [hidden secret-bearing arguments]", tokens[..tokens.len().min(keep)].join(" ") ) }) }) .unwrap_or_else(|| "[hidden secret-bearing command]".to_owned()); return command; } let mut displayed = self.input.clone(); displayed.insert(self.cursor, '▏'); displayed } pub fn clear(&mut self) { self.input.clear(); self.cursor = 0; self.history_position = None; self.history_draft.clear(); self.completion = None; } pub fn insert(&mut self, character: char) { if character.is_control() { return; } self.reset_navigation(); self.input.insert(self.cursor, character); self.cursor += character.len_utf8(); } pub fn backspace(&mut self) { self.reset_navigation(); if let Some(previous) = previous_boundary(&self.input, self.cursor) { self.input.drain(previous..self.cursor); self.cursor = previous; } } pub fn delete(&mut self) { self.reset_navigation(); if let Some(next) = next_boundary(&self.input, self.cursor) { self.input.drain(self.cursor..next); } } pub fn move_left(&mut self) { self.completion = None; if let Some(previous) = previous_boundary(&self.input, self.cursor) { self.cursor = previous; } } pub fn move_right(&mut self) { self.completion = None; if let Some(next) = next_boundary(&self.input, self.cursor) { self.cursor = next; } } pub fn move_home(&mut self) { self.completion = None; self.cursor = 0; } pub fn move_end(&mut self) { self.completion = None; self.cursor = self.input.len(); } pub fn previous_history(&mut self) { self.completion = None; if self.history.is_empty() { return; } let position = match self.history_position { None => { self.history_draft.clone_from(&self.input); self.history.len() - 1 } Some(position) => position.saturating_sub(1), }; self.history_position = Some(position); self.input.clone_from(&self.history[position]); self.cursor = self.input.len(); } pub fn next_history(&mut self) { self.completion = None; let Some(position) = self.history_position else { return; }; if position + 1 < self.history.len() { self.history_position = Some(position + 1); self.input.clone_from(&self.history[position + 1]); } else { self.history_position = None; self.input.clone_from(&self.history_draft); } self.cursor = self.input.len(); } pub fn complete(&mut self, paths: &[String], reverse: bool) -> bool { if let Some(cycle) = self.completion.as_mut() { cycle.selected = if reverse { cycle .selected .checked_sub(1) .unwrap_or(cycle.candidates.len() - 1) } else { (cycle.selected + 1) % cycle.candidates.len() }; let replacement = cycle.candidates[cycle.selected].clone(); self.input .replace_range(cycle.start..cycle.end, &replacement); cycle.end = cycle.start + replacement.len(); self.cursor = cycle.end; return true; } let fragment = completion_fragment(&self.input, self.cursor); let preceding = tokenize(&self.input[..fragment.start]).unwrap_or_default(); let candidates = completion_candidates(&preceding, &fragment.value, paths) .into_iter() .map(|candidate| quote_argument(&candidate)) .collect::>(); if candidates.is_empty() { return false; } let selected = if reverse { candidates.len() - 1 } else { 0 }; let replacement = if candidates.len() == 1 { format!("{} ", candidates[0]) } else { candidates[selected].clone() }; self.input .replace_range(fragment.start..fragment.end, &replacement); self.cursor = fragment.start + replacement.len(); if candidates.len() > 1 { self.completion = Some(CompletionCycle { start: fragment.start, end: self.cursor, candidates, selected, }); } true } pub fn submit(&mut self) -> Result { let raw = self.input.trim().to_owned(); let result = parse_command(&raw); if !raw.is_empty() && !is_secret_bearing(&raw) && self.history.last() != Some(&raw) { self.history.push(raw); if self.history.len() > HISTORY_LIMIT { self.history.remove(0); } } self.clear(); result } fn reset_navigation(&mut self) { self.history_position = None; self.history_draft.clear(); self.completion = None; } } pub fn parse_command(input: &str) -> Result { let input = input.trim().strip_prefix(':').unwrap_or(input.trim()); if input.is_empty() { return Err(CommandError::new("command is empty")); } let sensitive = is_secret_bearing(input); let tokens = tokenize(input).map_err(|error| { if sensitive { CommandError::new("invalid secret-bearing command; arguments omitted") } else { error } })?; let Some(command) = tokens.first().map(String::as_str) else { return Err(CommandError::new("command is empty")); }; if tokens.as_slice() == ["git", "resolve-local"] { return Ok(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Local, )); } if tokens.as_slice() == ["git", "resolve-remote"] { return Ok(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Remote, )); } let builtin = match command { "lock" => Some(CommandInvocation::Lock), "unlock" => Some(CommandInvocation::Unlock), "quit" | "q" => Some(CommandInvocation::Quit), "git-resolve-local" => Some(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Local, )), "git-resolve-remote" => Some(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Remote, )), _ => None, }; if let Some(invocation) = builtin { if tokens.len() != 1 { return Err(CommandError::new(format!( ":{command} does not accept arguments" ))); } return Ok(invocation); } let arguments = std::iter::once("ironstorage".to_owned()).chain(tokens); match parse_from(arguments) { Ok(CliAction::Display(text)) => Ok(CommandInvocation::Display(text)), Ok(CliAction::Run(invocation)) => { let (config, request) = invocation.into_parts(); if config.is_some() { return Err(CommandError::new( "the TUI uses its loaded configuration; --config is unavailable in command mode", )); } if matches!(request, CommandRequest::Completion { .. }) { return Err(CommandError::new( "shell completion scripts are unavailable inside the interactive TUI", )); } Ok(CommandInvocation::Storage(request)) } Err(_error) if sensitive => Err(CommandError::new( "invalid secret-bearing command; arguments omitted", )), Err(error) => Err(CommandError::new(error.to_string().trim().to_owned())), } } pub fn operation_name(request: &CommandRequest) -> &'static str { match request { CommandRequest::Init(_) => "initialization", CommandRequest::List(_) => "list", CommandRequest::Show(_) => "show", CommandRequest::Find(_) => "find", CommandRequest::Grep(_) => "grep", CommandRequest::Insert(_) => "insert", CommandRequest::Edit(_) => "edit", CommandRequest::Generate(_) => "generate", CommandRequest::Remove(_) => "remove", CommandRequest::Move(_) => "move", CommandRequest::Copy(_) => "copy", CommandRequest::ImportKdbx(_) => "KDBX import", CommandRequest::Git(request) => match request { GitRequest::Init => "Git init", GitRequest::Status => "Git status", GitRequest::Log { .. } => "Git log", GitRequest::Diff { .. } => "Git diff", GitRequest::Add { .. } => "Git add", GitRequest::Commit { .. } => "Git commit", GitRequest::Remote(_) => "Git remote", GitRequest::Config(_) => "Git config", GitRequest::Fetch { .. } => "Git fetch", GitRequest::Pull { .. } => "Git pull", GitRequest::Push { .. } => "Git push", GitRequest::Sync { .. } => "Git sync", }, CommandRequest::Otp(request) => match request { OtpRequest::Code(_) => "OTP code", OtpRequest::Insert(_) => "OTP insert", OtpRequest::Append(_) => "OTP append", OtpRequest::Uri(_) => "OTP URI", OtpRequest::Validate { .. } => "OTP validation", OtpRequest::Help => "OTP help", OtpRequest::Version => "OTP version", }, CommandRequest::Completion { .. } => "completion", CommandRequest::Help { .. } => "help", CommandRequest::Version => "version", } } fn tokenize(input: &str) -> Result, CommandError> { #[derive(Clone, Copy, Eq, PartialEq)] enum Quote { None, Single, Double, } let mut tokens = Vec::new(); let mut token = String::new(); let mut quote = Quote::None; let mut escaped = false; let mut started = false; for character in input.chars() { if escaped { token.push(character); escaped = false; started = true; continue; } match (quote, character) { (Quote::None | Quote::Double, '\\') => { escaped = true; started = true; } (Quote::None, '\'') => { quote = Quote::Single; started = true; } (Quote::Single, '\'') => quote = Quote::None, (Quote::None, '"') => { quote = Quote::Double; started = true; } (Quote::Double, '"') => quote = Quote::None, (Quote::None, character) if character.is_whitespace() => { if started { tokens.push(std::mem::take(&mut token)); started = false; } } (_, character) => { token.push(character); started = true; } } } if escaped { return Err(CommandError::new("command ends with an incomplete escape")); } if quote != Quote::None { return Err(CommandError::new("command contains an unterminated quote")); } if started { tokens.push(token); } Ok(tokens) } #[derive(Debug)] struct CompletionFragment { start: usize, end: usize, value: String, } fn completion_fragment(input: &str, cursor: usize) -> CompletionFragment { let mut start = 0; let mut quote = None; let mut escaped = false; for (index, character) in input[..cursor].char_indices() { if escaped { escaped = false; continue; } match (quote, character) { (Some('"'), '\\') | (None, '\\') => escaped = true, (None, '\'' | '"') => quote = Some(character), (Some(active), character) if active == character => quote = None, (None, character) if character.is_whitespace() => { start = index + character.len_utf8(); } _ => {} } } let raw = &input[start..cursor]; let value = tokenize(raw) .ok() .and_then(|mut tokens| tokens.pop()) .unwrap_or_else(|| raw.trim_start_matches(['\'', '"']).replace("\\ ", " ")); CompletionFragment { start, end: cursor, value, } } fn completion_candidates(preceding: &[String], fragment: &str, paths: &[String]) -> Vec { let candidates: &[&str] = match preceding { [] => ROOT_COMMANDS, [command] if command == "git" => GIT_COMMANDS, [command, remote] if command == "git" && remote == "remote" => GIT_REMOTE_COMMANDS, [command] if command == "otp" => OTP_COMMANDS, [command] if command == "help" => HELP_TOPICS, [command, ..] if command == "init" && fragment.starts_with('-') => &["--path"], [command, ..] if command == "show" && fragment.starts_with('-') => &["--clip", "--qrcode"], [command, ..] if command == "grep" && fragment.starts_with('-') => &[ "--ignore-case", "--invert-match", "--line-number", "--fixed-strings", ], [command, ..] if command == "generate" && fragment.starts_with('-') => &[ "--no-symbols", "--clip", "--qrcode", "--in-place", "--force", ], [command, ..] if command == "insert" && fragment.starts_with('-') => { &["--echo", "--multiline", "--force"] } [command, ..] if command == "remove" && fragment.starts_with('-') => { &["--recursive", "--force"] } [command, ..] if matches!(command.as_str(), "move" | "copy") && fragment.starts_with('-') => { &["--force"] } [git, subcommand, ..] if git == "git" && subcommand == "log" && fragment.starts_with('-') => { &["--max-count"] } [git, subcommand, ..] if git == "git" && subcommand == "commit" && fragment.starts_with('-') => { &["--message"] } [git, subcommand, ..] if git == "git" && subcommand == "config" && fragment.starts_with('-') => { &["--get"] } [otp, subcommand, ..] if otp == "otp" && subcommand == "code" && fragment.starts_with('-') => { &["--clip"] } [otp, subcommand, ..] if otp == "otp" && matches!(subcommand.as_str(), "insert" | "append") && fragment.starts_with('-') => { &["--force", "--echo", "--secret", "--issuer", "--account"] } [otp, subcommand, ..] if otp == "otp" && subcommand == "uri" && fragment.starts_with('-') => { &["--clip", "--qrcode"] } _ => &[], }; let mut matches = candidates .iter() .filter(|candidate| candidate.starts_with(fragment)) .map(|candidate| (*candidate).to_owned()) .collect::>(); if candidates.is_empty() && command_accepts_path(preceding) { matches.extend( paths .iter() .filter(|path| path.starts_with(fragment)) .cloned(), ); } matches.sort(); matches.dedup(); matches } fn command_accepts_path(tokens: &[String]) -> bool { matches!( tokens.first().map(String::as_str), Some( "list" | "ls" | "show" | "insert" | "edit" | "generate" | "remove" | "rm" | "move" | "mv" | "copy" | "cp" ) ) || matches!( tokens, [command, subcommand, ..] if (command == "otp" && matches!(subcommand.as_str(), "code" | "show" | "insert" | "append" | "uri")) || (command == "git" && matches!(subcommand.as_str(), "diff" | "add")) ) } fn quote_argument(argument: &str) -> String { if !argument.is_empty() && argument .chars() .all(|character| character.is_alphanumeric() || "/._-:@".contains(character)) { return argument.to_owned(); } format!("'{}'", argument.replace('\'', "'\\''")) } fn is_secret_bearing(input: &str) -> bool { let lowercase = input.to_ascii_lowercase(); if lowercase.contains("otpauth://") { return true; } let tokens = tokenize(input).unwrap_or_else(|_| { input .split_whitespace() .map(str::to_owned) .collect::>() }); matches!(tokens.as_slice(), [otp, validate, ..] if otp == "otp" && validate == "validate") || matches!(tokens.as_slice(), [git, config, ..] if git == "git" && config == "config") || matches!(tokens.as_slice(), [git, remote, action, ..] if git == "git" && remote == "remote" && matches!(action.as_str(), "add" | "set-url")) } fn previous_boundary(value: &str, cursor: usize) -> Option { value[..cursor] .char_indices() .next_back() .map(|(index, _)| index) } fn next_boundary(value: &str, cursor: usize) -> Option { value[cursor..].char_indices().nth(1).map_or_else( || (cursor < value.len()).then_some(value.len()), |(index, _)| Some(cursor + index), ) } #[cfg(test)] mod tests { use super::*; use ironstorage::command::{GitRemoteRequest, RemoveRequest}; #[test] fn quoting_and_paths_with_spaces_use_the_shared_request_parser() { assert!(matches!( parse_command("show 'team accounts/mail primary'"), Ok(CommandInvocation::Storage(CommandRequest::Show(request))) if request.entry.as_deref() == Some("team accounts/mail primary") )); assert!(matches!( parse_command("git commit -m \"rotate signing key\""), Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Commit { message }))) if message == "rotate signing key" )); assert!(parse_command("show 'unfinished").is_err()); } #[test] fn editing_history_and_secret_omission_are_unicode_safe() { let mut line = CommandLine::default(); for character in "show café/mail".chars() { line.insert(character); } line.move_left(); line.backspace(); line.insert('i'); assert_eq!(line.input(), "show café/mail"); assert!(line.submit().is_ok()); assert_eq!(line.history(), &["show café/mail"]); for character in "otp validate otpauth://totp/a?secret=TOPSECRET".chars() { line.insert(character); } assert!(!line.display().contains("TOPSECRET")); assert!(line.submit().is_ok()); assert_eq!(line.history(), &["show café/mail"]); line.previous_history(); assert_eq!(line.input(), "show café/mail"); line.next_history(); assert!(line.input().is_empty()); } #[test] fn completion_is_context_aware_and_quotes_paths() { let paths = vec!["team accounts/mail primary".to_owned()]; let mut line = CommandLine::default(); for character in "gi".chars() { line.insert(character); } assert!(line.complete(&paths, false)); assert_eq!(line.input(), "git "); for character in "pu".chars() { line.insert(character); } assert!(line.complete(&paths, false)); assert!(matches!(line.input(), "git pull" | "git push")); line.clear(); for character in "show team".chars() { line.insert(character); } assert!(line.complete(&paths, false)); assert_eq!(line.input(), "show 'team accounts/mail primary' "); } #[test] fn invalid_flags_cancellation_inputs_and_unavailable_meta_actions_are_typed() { assert!(parse_command("generate --clip --qrcode entry").is_err()); assert!( parse_command("completion bash") .expect_err("interactive completion is unavailable") .to_string() .contains("unavailable") ); assert!(matches!( parse_command(":lock"), Ok(CommandInvocation::Lock) )); assert!(matches!( parse_command(":unlock"), Ok(CommandInvocation::Unlock) )); assert!(matches!( parse_command(":quit"), Ok(CommandInvocation::Quit) )); } #[test] fn destructive_and_nested_requests_preserve_typed_storage_contracts() { assert_eq!( parse_command("git resolve-local"), Ok(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Local )) ); assert_eq!( parse_command("git resolve-remote"), Ok(CommandInvocation::ResolveGit( ironstorage::git::GitConflictChoice::Remote )) ); assert_eq!( parse_command("remove -r folder"), Ok(CommandInvocation::Storage(CommandRequest::Remove( RemoveRequest { entry: "folder".to_owned(), recursive: true, force: false, } ))) ); assert!(matches!( parse_command("git remote set-url origin https://example.test/repo"), Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Remote( GitRemoteRequest::SetUrl { name, url } )))) if name == "origin" && url == "https://example.test/repo" )); } #[test] fn coverage_table_has_every_command_family_and_no_duplicates() { assert!(COMMAND_COVERAGE.len() >= 33); for (index, row) in COMMAND_COVERAGE.iter().enumerate() { assert!(row.command.starts_with(':')); assert!(!row.operation.is_empty()); assert!(!COMMAND_COVERAGE[index + 1..].contains(row)); } for command in [ ":init", ":list", ":show", ":find", ":grep", ":insert", ":edit", ":generate", ":remove", ":move", ":copy", ":git", ":otp", ":lock", ":unlock", ":help", ":version", ] { assert!( COMMAND_COVERAGE .iter() .any(|row| row.command.starts_with(command)) ); } for command_row in COMMAND_COVERAGE { assert!( TUI_COVERAGE .iter() .any(|row| row.operation == command_row.operation), "missing TUI coverage for {}", command_row.operation ); } for (index, row) in TUI_COVERAGE.iter().enumerate() { assert!(!row.operation.is_empty()); assert!(!row.element.is_empty()); assert!(row.command.starts_with(':')); assert!( !TUI_COVERAGE[index + 1..] .iter() .any(|other| other.operation == row.operation), "duplicate TUI operation {}", row.operation ); if row.action.is_some() { assert!( row.default_hotkey().is_some(), "{} has no registered default hotkey", row.operation ); } } } #[test] fn command_mode_source_has_no_process_launch_boundary() { let source = include_str!("command.rs"); let process_type = ["process", "::Command"].concat(); let constructor = ["Command", "::new("].concat(); for forbidden in [process_type, constructor] { assert!(!source.contains(&forbidden)); } for executable in ["pass ", "git ", "gpg ", "ironstorage "] { assert!(!source.contains(&format!("{executable}--"))); } } }