use crate::ui;
use std::io::Write;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Create,
Claim,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Level {
Empty,
Incomplete,
Ready,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Judgment {
pub level: Level,
pub steer: String,
pub preview: Option<String>,
}
pub fn format_buffer(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_dash = false;
for ch in raw.chars() {
let mapped = if ch.is_whitespace() { '-' } else { ch.to_ascii_lowercase() };
if mapped == '-' {
if prev_dash || out.is_empty() {
continue;
}
out.push('-');
prev_dash = true;
} else if mapped.is_ascii_lowercase() || mapped.is_ascii_digit() {
out.push(mapped);
prev_dash = false;
}
}
out
}
fn is_nameplate(s: &str) -> bool {
(3..=5).contains(&s.len()) && !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
pub fn judge(buf: &str, mode: Mode, auto_nameplate: &str) -> Judgment {
let normalized = crate::pake::norm_code(buf);
let trimmed = normalized.trim_matches('-');
if trimmed.is_empty() {
let steer = match mode {
Mode::Claim => "type the code they read you, e.g. brave-otter-3141".to_string(),
Mode::Create => "type two words, easier to say and harder to guess".to_string(),
};
return Judgment { level: Level::Empty, steer, preview: None };
}
match mode {
Mode::Claim => {
let (np, pw) = crate::pake::split_code(trimmed);
let has_words = !pw.is_empty();
let np_ok = is_nameplate(&np);
if np_ok && has_words {
return Judgment {
level: Level::Ready,
steer: "✓ ready, press enter".to_string(),
preview: Some(format!("{pw} · {np}")),
};
}
if !has_words {
let bare_is_number = np.bytes().all(|b| b.is_ascii_digit());
let steer = if bare_is_number {
"add the words before the number".to_string()
} else {
"keep going, a full code is words then a 3-5 digit number".to_string()
};
return Judgment {
level: Level::Incomplete,
steer,
preview: None,
};
}
Judgment {
level: Level::Incomplete,
steer: "keep going, a full code ends in a 3-5 digit number".to_string(),
preview: None,
}
}
Mode::Create => {
let (words, _np) = crate::pake::split_chosen_code(trimmed);
if crate::password_word_tokens(&words) < 2 {
return Judgment {
level: Level::Incomplete,
steer: "add another word, easier to say and harder to guess".to_string(),
preview: None,
};
}
Judgment {
level: Level::Ready,
steer: format!("✓ {words}, press enter to create"),
preview: Some(format!("{words}-{auto_nameplate}")),
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
Submitted(String),
Empty,
Cancelled,
}
pub struct RawGuard {
active: bool,
}
impl RawGuard {
pub fn enable() -> std::io::Result<RawGuard> {
crossterm::terminal::enable_raw_mode()?;
Ok(RawGuard { active: true })
}
}
impl Drop for RawGuard {
fn drop(&mut self) {
if self.active {
let _ = crossterm::terminal::disable_raw_mode();
let mut err = std::io::stderr();
let _ = crossterm::execute!(err, crossterm::cursor::Show);
let _ = write!(err, "\r\n");
let _ = err.flush();
}
}
}
fn level_color(level: Level) -> Option<&'static str> {
if !ui::caps().color {
return None;
}
Some(match level {
Level::Empty => "\x1b[2m", Level::Incomplete => "\x1b[33m", Level::Ready => "\x1b[32m", })
}
fn render(prompt: &str, buf: &str, j: &Judgment) {
let mut err = std::io::stderr();
let reset = if ui::caps().color { "\x1b[0m" } else { "" };
let color = level_color(j.level).unwrap_or("");
let preview = match &j.preview {
Some(p) => {
if ui::caps().color { format!(" \x1b[2m{p}{reset}") } else { format!(" {p}") }
}
None => String::new(),
};
let _ = write!(err, "\r\x1b[2K{prompt}{buf} {color}{}{reset}{preview}", j.steer);
let col = prompt.chars().count() + buf.chars().count();
let _ = write!(err, "\r");
if col > 0 {
let _ = write!(err, "\x1b[{col}C");
}
let _ = err.flush();
}
fn render_picker(header: &str, items: &[String], sel: usize, redraw: bool) {
let mut err = std::io::stderr();
let color = ui::caps().color;
if redraw {
let _ = write!(err, "\x1b[{}A", items.len() + 1);
}
let _ = write!(err, "\r\x1b[2K{header}\r\n");
for (i, it) in items.iter().enumerate() {
let (mark, c, r) = if i == sel {
("\u{276f}", if color { "\x1b[36m" } else { "" }, if color { "\x1b[0m" } else { "" })
} else {
(" ", "", "")
};
let _ = write!(err, "\r\x1b[2K {c}{mark} {it}{r}\r\n");
}
let _ = err.flush();
}
pub fn pick(header: &str, items: &[String]) -> std::io::Result<Option<usize>> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
if items.is_empty() {
return Ok(None);
}
let _guard = RawGuard::enable()?;
let mut sel = 0usize;
let mut redraw = false;
loop {
render_picker(header, items, sel, redraw);
redraw = true;
let ev = event::read()?;
let Event::Key(k) = ev else { continue };
if k.kind == KeyEventKind::Release {
continue;
}
match k.code {
KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => return Ok(None),
KeyCode::Up | KeyCode::Char('k') => sel = if sel == 0 { items.len() - 1 } else { sel - 1 },
KeyCode::Down | KeyCode::Char('j') => sel = (sel + 1) % items.len(),
KeyCode::Enter => return Ok(Some(sel)),
KeyCode::Esc | KeyCode::Char('q') => return Ok(None),
_ => {}
}
}
}
pub fn run(prompt: &str, mode: Mode, prefill: &str, auto_nameplate: &str) -> std::io::Result<Outcome> {
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
let _guard = RawGuard::enable()?;
let mut buf = format_buffer(prefill);
{
let j = judge(&buf, mode, auto_nameplate);
render(prompt, &buf, &j);
}
loop {
let ev = event::read()?;
let Event::Key(KeyEvent { code, modifiers, kind, .. }) = ev else { continue };
if kind == KeyEventKind::Release {
continue;
}
match code {
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(Outcome::Cancelled);
}
KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => {
if buf.is_empty() {
return Ok(Outcome::Cancelled);
}
}
KeyCode::Esc => return Ok(Outcome::Cancelled),
KeyCode::Enter => {
let j = judge(&buf, mode, auto_nameplate);
match j.level {
Level::Ready => {
let result = match mode {
Mode::Create => {
let (words, _np) = crate::pake::split_chosen_code(
&crate::pake::norm_code(&buf),
);
words
}
Mode::Claim => crate::pake::norm_code(&buf),
};
return Ok(Outcome::Submitted(result));
}
Level::Empty => return Ok(Outcome::Empty),
Level::Incomplete => { }
}
}
KeyCode::Backspace => {
buf.pop();
buf = format_buffer(&buf);
}
KeyCode::Char(c) => {
let mut candidate = buf.clone();
candidate.push(c);
buf = format_buffer(&candidate);
}
_ => {}
}
let j = judge(&buf, mode, auto_nameplate);
render(prompt, &buf, &j);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_lowercases_and_keeps_valid() {
assert_eq!(format_buffer("Brave-Otter-3141"), "brave-otter-3141");
assert_eq!(format_buffer("ABC123"), "abc123");
}
#[test]
fn format_maps_space_to_dash() {
assert_eq!(format_buffer("gigantic element"), "gigantic-element");
assert_eq!(format_buffer("brave otter ruby"), "brave-otter-ruby");
}
#[test]
fn format_collapses_dashes_and_strips_leading() {
assert_eq!(format_buffer("brave---otter"), "brave-otter");
assert_eq!(format_buffer("---brave"), "brave");
assert_eq!(format_buffer(" leading"), "leading");
assert_eq!(format_buffer("a b"), "a-b");
}
#[test]
fn format_rejects_bad_chars_entirely() {
assert_eq!(format_buffer("brave!otter"), "braveotter");
assert_eq!(format_buffer("a@b/c"), "abc");
assert_eq!(format_buffer("café"), "caf"); assert_eq!(format_buffer("UP!!!"), "up");
}
#[test]
fn format_keeps_trailing_dash_midtype() {
assert_eq!(format_buffer("brave-"), "brave-");
}
const NP: &str = "1234";
#[test]
fn claim_empty_is_dim_hint() {
let j = judge("", Mode::Claim, NP);
assert_eq!(j.level, Level::Empty);
assert!(j.steer.contains("brave-otter-3141"));
assert_eq!(j.preview, None);
}
#[test]
fn claim_words_without_nameplate_is_amber() {
let j = judge("brave-otter", Mode::Claim, NP);
assert_eq!(j.level, Level::Incomplete);
assert!(j.steer.contains("3-5 digit"));
}
#[test]
fn claim_bare_number_steers_to_add_words() {
let j = judge("3141", Mode::Claim, NP);
assert_eq!(j.level, Level::Incomplete);
assert!(j.steer.contains("add the words"));
}
#[test]
fn claim_lone_word_nudges_to_full_shape() {
let j = judge("brave", Mode::Claim, NP);
assert_eq!(j.level, Level::Incomplete);
assert!(j.steer.contains("words then a 3-5 digit number"));
assert!(!j.steer.contains("add the words before the number"));
}
#[test]
fn claim_full_code_is_ready_with_preview() {
let j = judge("brave-otter-3141", Mode::Claim, NP);
assert_eq!(j.level, Level::Ready);
assert!(j.steer.contains("ready"));
assert_eq!(j.preview.as_deref(), Some("brave-otter · 3141"));
}
#[test]
fn claim_three_word_code_is_ready() {
let j = judge("brave-otter-ruby-314", Mode::Claim, NP);
assert_eq!(j.level, Level::Ready);
assert_eq!(j.preview.as_deref(), Some("brave-otter-ruby · 314"));
}
#[test]
fn claim_short_trailing_group_not_nameplate() {
let j = judge("brave-otter-31", Mode::Claim, NP);
assert_eq!(j.level, Level::Incomplete);
}
#[test]
fn claim_tolerates_messy_input() {
let j = judge("BRAVE OTTER!! 3141", Mode::Claim, NP);
assert_eq!(j.level, Level::Ready);
}
#[test]
fn create_empty_is_dim_hint() {
let j = judge("", Mode::Create, NP);
assert_eq!(j.level, Level::Empty);
}
#[test]
fn create_single_word_is_amber() {
let j = judge("gigantic", Mode::Create, NP);
assert_eq!(j.level, Level::Incomplete);
assert!(j.steer.contains("add another word"));
}
#[test]
fn create_two_words_is_ready_with_auto_nameplate() {
let j = judge("gigantic-element", Mode::Create, NP);
assert_eq!(j.level, Level::Ready);
assert!(j.steer.contains("gigantic-element"));
assert_eq!(j.preview.as_deref(), Some("gigantic-element-1234"));
}
#[test]
fn create_strips_user_typed_number_from_password() {
let j = judge("gigantic-element-999", Mode::Create, NP);
assert_eq!(j.level, Level::Ready);
assert_eq!(j.preview.as_deref(), Some("gigantic-element-1234"));
}
#[test]
fn create_space_separated_words_ready() {
let j = judge("brave otter", Mode::Create, NP);
assert_eq!(j.level, Level::Ready);
}
#[test]
fn create_single_word_plus_number_still_amber() {
let j = judge("cat-12345", Mode::Create, NP);
assert_eq!(j.level, Level::Incomplete);
}
}