use std::sync::{Arc, Mutex};
use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::Result as RustylineResult;
use rustyline_derive::Helper;
use super::commands::{COMMAND_NAMES, TYPE_NAMES};
const COMMANDS_WITH_TYPES: &[&str] = &[
"show", "add", "edit", "delete", "export",
];
const COMMANDS_WITH_ENTRIES: &[&str] = &[
"show", "edit", "delete",
];
#[derive(Helper)]
pub struct ReplHelper {
pub entry_names: Arc<Mutex<Vec<String>>>,
}
impl ReplHelper {
pub fn new(entry_names: Arc<Mutex<Vec<String>>>) -> Self {
ReplHelper { entry_names }
}
}
impl Validator for ReplHelper {}
impl Highlighter for ReplHelper {}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
None
}
}
fn is_type_name(token: &str) -> bool {
TYPE_NAMES.iter().any(|t| t.eq_ignore_ascii_case(token))
}
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> RustylineResult<(usize, Vec<Self::Candidate>)> {
let line_up_to_cursor = &line[..pos];
let tokens: Vec<&str> = line_up_to_cursor.split_whitespace().collect();
let trailing_space = line_up_to_cursor.ends_with(' ');
if tokens.is_empty() || (tokens.len() == 1 && !trailing_space) {
let prefix = tokens.first().copied().unwrap_or("");
let start = pos - prefix.len();
let matches = complete_from_list(prefix, COMMAND_NAMES);
Ok((start, matches))
} else if (tokens.len() == 1 && trailing_space) || (tokens.len() == 2 && !trailing_space) {
let command = tokens[0].to_lowercase();
let prefix = if trailing_space { "" } else { tokens[1] };
let start = pos - prefix.len();
if COMMANDS_WITH_TYPES.contains(&command.as_str()) {
let mut matches = complete_from_list(prefix, TYPE_NAMES);
if COMMANDS_WITH_ENTRIES.contains(&command.as_str()) {
let entry_matches = self.complete_entry_names(prefix);
matches.extend(entry_matches);
}
Ok((start, matches))
} else {
Ok((pos, vec![]))
}
} else if (tokens.len() == 2 && trailing_space) || (tokens.len() == 3 && !trailing_space) {
let command = tokens[0].to_lowercase();
if COMMANDS_WITH_ENTRIES.contains(&command.as_str()) {
let second = tokens[1].to_lowercase();
if is_type_name(&second) || !trailing_space && tokens.len() == 3 {
let prefix = if trailing_space { "" } else { tokens[2] };
let start = pos - prefix.len();
let matches = self.complete_entry_names(prefix);
Ok((start, matches))
} else {
Ok((pos, vec![]))
}
} else {
Ok((pos, vec![]))
}
} else {
Ok((pos, vec![]))
}
}
}
impl ReplHelper {
fn complete_entry_names(&self, prefix: &str) -> Vec<Pair> {
let lower_prefix = prefix.to_lowercase();
match self.entry_names.lock() {
Ok(names) => names
.iter()
.filter(|n| {
if lower_prefix.is_empty() {
true
} else {
n.to_lowercase().contains(&lower_prefix)
}
})
.map(|n| Pair {
display: n.clone(),
replacement: n.clone(),
})
.collect(),
Err(_) => vec![],
}
}
}
fn complete_from_list(prefix: &str, candidates: &[&str]) -> Vec<Pair> {
let lower_prefix = prefix.to_lowercase();
candidates
.iter()
.filter(|c| c.to_lowercase().starts_with(&lower_prefix))
.map(|c| Pair {
display: c.to_string(),
replacement: c.to_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_complete_commands() {
let matches = complete_from_list("sh", COMMAND_NAMES);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].replacement, "show");
}
#[test]
fn test_complete_commands_multiple() {
let matches = complete_from_list("e", COMMAND_NAMES);
let names: Vec<&str> = matches.iter().map(|p| p.replacement.as_str()).collect();
assert!(names.contains(&"edit"));
assert!(names.contains(&"export"));
assert!(names.contains(&"exit"));
}
#[test]
fn test_complete_types() {
let matches = complete_from_list("ca", TYPE_NAMES);
let names: Vec<&str> = matches.iter().map(|p| p.replacement.as_str()).collect();
assert!(names.contains(&"cards"));
assert!(names.contains(&"card"));
}
#[test]
fn test_complete_empty_prefix_commands() {
let matches = complete_from_list("", COMMAND_NAMES);
assert_eq!(matches.len(), COMMAND_NAMES.len());
}
#[test]
fn test_complete_no_match() {
let matches = complete_from_list("zzz", COMMAND_NAMES);
assert!(matches.is_empty());
}
#[test]
fn test_complete_case_insensitive() {
let matches = complete_from_list("SH", COMMAND_NAMES);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].replacement, "show");
}
#[test]
fn test_complete_entry_names_prefix() {
let names = Arc::new(Mutex::new(vec![
"github:alice".to_string(),
"google:bob".to_string(),
"gitlab:alice".to_string(),
]));
let helper = ReplHelper::new(names);
let matches = helper.complete_entry_names("gi");
let results: Vec<&str> = matches.iter().map(|p| p.replacement.as_str()).collect();
assert!(results.contains(&"github:alice"));
assert!(results.contains(&"gitlab:alice"));
assert!(!results.contains(&"google:bob"));
}
#[test]
fn test_complete_entry_names_substring() {
let names = Arc::new(Mutex::new(vec![
"github:alice".to_string(),
"google:bob".to_string(),
"gitlab:alice".to_string(),
]));
let helper = ReplHelper::new(names);
let matches = helper.complete_entry_names("alice");
let results: Vec<&str> = matches.iter().map(|p| p.replacement.as_str()).collect();
assert!(results.contains(&"github:alice"));
assert!(results.contains(&"gitlab:alice"));
assert!(!results.contains(&"google:bob"));
}
#[test]
fn test_complete_entry_names_empty_prefix() {
let names = Arc::new(Mutex::new(vec![
"github:alice".to_string(),
"google:bob".to_string(),
]));
let helper = ReplHelper::new(names);
let matches = helper.complete_entry_names("");
assert_eq!(matches.len(), 2);
}
#[test]
fn test_complete_entry_names_no_match() {
let names = Arc::new(Mutex::new(vec!["github:alice".to_string()]));
let helper = ReplHelper::new(names);
let matches = helper.complete_entry_names("zzz");
assert!(matches.is_empty());
}
#[test]
fn test_complete_entry_names_colon_pair() {
let names = Arc::new(Mutex::new(vec![
"github:alice".to_string(),
"github:bob".to_string(),
]));
let helper = ReplHelper::new(names);
let matches = helper.complete_entry_names("github:a");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].replacement, "github:alice");
}
#[test]
fn test_is_type_name() {
assert!(is_type_name("creds"));
assert!(is_type_name("CREDS"));
assert!(is_type_name("cards"));
assert!(is_type_name("otp"));
assert!(!is_type_name("github"));
}
}