Implement TUI colon command mode
This commit is contained in:
826
apps/tui/src/command.rs
Normal file
826
apps/tui/src/command.rs
Normal file
@@ -0,0 +1,826 @@
|
||||
//! 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};
|
||||
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
|
||||
const ROOT_COMMANDS: &[&str] = &[
|
||||
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
|
||||
"git", "otp", "lock", "unlock", "help", "version", "quit",
|
||||
];
|
||||
const GIT_COMMANDS: &[&str] = &[
|
||||
"init", "status", "log", "diff", "add", "commit", "remote", "config", "fetch", "pull", "push",
|
||||
"sync",
|
||||
];
|
||||
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",
|
||||
"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("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("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"),
|
||||
];
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CommandError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
fn new(message: impl Into<String>) -> 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<String>,
|
||||
history_position: Option<usize>,
|
||||
history_draft: String,
|
||||
completion: Option<CompletionCycle>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CompletionCycle {
|
||||
start: usize,
|
||||
end: usize,
|
||||
candidates: Vec<String>,
|
||||
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::<Vec<_>>();
|
||||
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<CommandInvocation, CommandError> {
|
||||
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<CommandInvocation, CommandError> {
|
||||
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"));
|
||||
};
|
||||
let builtin = match command {
|
||||
"lock" => Some(CommandInvocation::Lock),
|
||||
"unlock" => Some(CommandInvocation::Unlock),
|
||||
"quit" | "q" => Some(CommandInvocation::Quit),
|
||||
_ => 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::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<Vec<String>, 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<String> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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<usize> {
|
||||
value[..cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
fn next_boundary(value: &str, cursor: usize) -> Option<usize> {
|
||||
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("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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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}--")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user