use crate::shared::i18n::Locale;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextEdit {
Show,
Clear,
Set(String),
}
pub(crate) fn text_edit(raw: &str) -> TextEdit {
if raw.is_empty() {
TextEdit::Show
} else if raw.eq_ignore_ascii_case("clear") {
TextEdit::Clear
} else {
TextEdit::Set(raw.to_string())
}
}
pub(crate) fn name_argument(raw: &str) -> Option<String> {
let joined = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = joined.trim().trim_matches(|c| c == '"' || c == '\'').trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(crate) fn head_and_rest(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim();
let mut parts = trimmed.splitn(2, char::is_whitespace);
let head = parts.next().filter(|h| !h.is_empty())?;
Some((head, parts.next().unwrap_or("").trim()))
}
pub(crate) fn subcommand_of<'a>(
input: &'a str,
family: &str,
) -> Option<Option<(&'a str, &'a str)>> {
let (first, rest) = head_and_rest(input)?;
if !first.eq_ignore_ascii_case(family) {
return None;
}
Some(head_and_rest(rest))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileCommand {
List,
New { name: Option<String> },
Delete { name: String },
System(TextEdit),
Greeting(TextEdit),
}
pub fn parse(input: &str, loc: &Locale) -> Option<Result<ProfileCommand, String>> {
let parts = subcommand_of(input, "/profile")?;
let usage = loc.t("ui.profile.usage");
let Some((sub, raw)) = parts else {
return Some(Err(
loc.tf("ui.profile.err.missing_subcommand", &[("usage", usage)])
));
};
if sub.eq_ignore_ascii_case("list") {
Some(Ok(ProfileCommand::List))
} else if sub.eq_ignore_ascii_case("new") {
Some(Ok(ProfileCommand::New {
name: name_argument(raw),
}))
} else if sub.eq_ignore_ascii_case("delete") {
match name_argument(raw) {
Some(name) => Some(Ok(ProfileCommand::Delete { name })),
None => Some(Err(
loc.tf("ui.profile.err.missing_name", &[("usage", usage)])
)),
}
} else if sub.eq_ignore_ascii_case("system") {
Some(Ok(ProfileCommand::System(text_edit(raw))))
} else if sub.eq_ignore_ascii_case("greeting") {
Some(Ok(ProfileCommand::Greeting(text_edit(raw))))
} else {
Some(Err(loc.tf(
"ui.profile.err.unknown_subcommand",
&[("sub", sub), ("usage", usage)],
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
#[test]
fn subcommands_parse_in_any_case_and_padding() {
for (text, expected) in [
("/profile list", ProfileCommand::List),
(" /PROFILE List ", ProfileCommand::List),
("/profile new", ProfileCommand::New { name: None }),
(
"/profile new Гайя",
ProfileCommand::New {
name: Some("Гайя".into()),
},
),
(
"/profile delete Гайя",
ProfileCommand::Delete {
name: "Гайя".into(),
},
),
("/profile system", ProfileCommand::System(TextEdit::Show)),
(
"/profile SYSTEM CLEAR",
ProfileCommand::System(TextEdit::Clear),
),
(
"/profile system Ты — Гайя.",
ProfileCommand::System(TextEdit::Set("Ты — Гайя.".into())),
),
(
"/profile greeting",
ProfileCommand::Greeting(TextEdit::Show),
),
(
"/profile greeting clear",
ProfileCommand::Greeting(TextEdit::Clear),
),
(
"/profile greeting Привет!",
ProfileCommand::Greeting(TextEdit::Set("Привет!".into())),
),
] {
assert_eq!(parse(text, ru()), Some(Ok(expected)), "input {text:?}");
}
}
#[test]
fn a_name_keeps_its_spaces_and_loses_its_quotes() {
for text in [
"/profile new Дневной помощник",
"/profile new \"Дневной помощник\"",
] {
assert_eq!(
parse(text, ru()),
Some(Ok(ProfileCommand::New {
name: Some("Дневной помощник".into())
})),
"input {text:?}"
);
}
}
#[test]
fn a_text_argument_keeps_newlines_and_quotes() {
let text = "/profile system Ты — «Гайя».\nОтвечай кратко.";
assert_eq!(
parse(text, ru()),
Some(Ok(ProfileCommand::System(TextEdit::Set(
"Ты — «Гайя».\nОтвечай кратко.".into()
))))
);
assert_eq!(
parse("/profile greeting clear the air", ru()),
Some(Ok(ProfileCommand::Greeting(TextEdit::Set(
"clear the air".into()
))))
);
}
#[test]
fn a_missing_or_unknown_subcommand_is_reported() {
for text in ["/profile", "/profile rename Гайя", "/profile delete"] {
assert!(matches!(parse(text, ru()), Some(Err(_))), "input {text:?}");
}
let Some(Err(msg)) = parse("/profile renam Гайя", ru()) else {
panic!("expected a report");
};
assert!(msg.contains("renam"), "{msg}");
}
#[test]
fn other_input_is_none() {
for text in [
"/profiles list",
"/profile-new",
"/new Гайя",
"profile list",
"how do I add a /profile?",
"",
] {
assert_eq!(parse(text, ru()), None, "input {text:?}");
}
}
#[test]
fn errors_are_localized_for_all_langs() {
for &lang in Lang::ALL {
let loc = locale(lang);
for text in ["/profile", "/profile delete", "/profile renam x"] {
let Some(Err(msg)) = parse(text, loc) else {
panic!("{text:?} should have been reported in {lang:?}");
};
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
assert!(
msg.contains("/profile"),
"the message must name the command in {lang:?}: {msg}"
);
if lang == Lang::En {
assert!(
!msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {msg}"
);
}
}
}
}
}