use crate::shared::i18n::Locale;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExportFormat {
#[default]
Markdown,
Json,
}
impl ExportFormat {
pub fn extension(self) -> &'static str {
match self {
ExportFormat::Markdown => "md",
ExportFormat::Json => "json",
}
}
fn from_word(word: &str) -> Option<Self> {
match word.to_ascii_lowercase().as_str() {
"md" | "markdown" => Some(ExportFormat::Markdown),
"json" => Some(ExportFormat::Json),
_ => None,
}
}
fn from_path(path: &str) -> Option<Self> {
let ext = std::path::Path::new(path).extension()?.to_str()?;
Self::from_word(ext)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportCommand {
pub format: ExportFormat,
pub path: Option<String>,
}
pub fn parse(input: &str, loc: &Locale) -> Option<Result<ExportCommand, String>> {
let trimmed = input.trim();
let mut parts = trimmed.splitn(2, char::is_whitespace);
let head = parts.next()?;
if !head.eq_ignore_ascii_case("/export") {
return None;
}
let rest = parts.next().unwrap_or_default().trim();
if rest.is_empty() {
return Some(Ok(ExportCommand {
format: ExportFormat::default(),
path: None,
}));
}
let mut words = rest.splitn(2, char::is_whitespace);
let first = words.next().unwrap_or_default();
if let Some(format) = ExportFormat::from_word(first) {
let path = words.next().unwrap_or_default().trim();
return Some(Ok(ExportCommand {
format,
path: clean_path(path),
}));
}
let path = clean_path(rest);
match path {
None => Some(Err(loc.t("ui.export.err.empty_path").to_string())),
Some(path) => Some(Ok(ExportCommand {
format: ExportFormat::from_path(&path).unwrap_or_default(),
path: Some(path),
})),
}
}
fn clean_path(path: &str) -> Option<String> {
let path = path.trim().trim_matches(|c| c == '"' || c == '\'').trim();
(!path.is_empty()).then(|| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
fn ok(input: &str) -> ExportCommand {
parse(input, ru())
.unwrap_or_else(|| panic!("{input:?} was not recognized"))
.unwrap_or_else(|e| panic!("{input:?} did not parse: {e}"))
}
#[test]
fn the_grammar() {
use ExportFormat::*;
for (input, format, path) in [
("/export", Markdown, None),
(" /EXPORT ", Markdown, None),
("/export json", Json, None),
("/export md", Markdown, None),
("/export MARKDOWN", Markdown, None),
("/export transcript.json", Json, Some("transcript.json")),
("/export transcript.md", Markdown, Some("transcript.md")),
("/export transcript.txt", Markdown, Some("transcript.txt")),
("/export notes", Markdown, Some("notes")),
("/export json transcript.txt", Json, Some("transcript.txt")),
(
"/export md transcript.json",
Markdown,
Some("transcript.json"),
),
(
"/export my transcript.md",
Markdown,
Some("my transcript.md"),
),
(
"/export \"my transcript.md\"",
Markdown,
Some("my transcript.md"),
),
(
"/export json 'out dir/a b.txt'",
Json,
Some("out dir/a b.txt"),
),
] {
let cmd = ok(input);
assert_eq!(cmd.format, format, "format of {input:?}");
assert_eq!(cmd.path.as_deref(), path, "path of {input:?}");
}
}
#[test]
fn an_absolute_path_survives() {
for path in ["/tmp/chat.md", "C:\\Users\\me\\chat.md", "~/chat.json"] {
assert_eq!(ok(&format!("/export {path}")).path.as_deref(), Some(path));
}
}
#[test]
fn quotes_around_nothing_are_reported() {
assert!(matches!(parse("/export \"\"", ru()), Some(Err(_))));
assert!(matches!(parse("/export ' '", ru()), Some(Err(_))));
}
#[test]
fn other_input_is_none() {
for text in [
"/exports",
"/export-now",
"/exit",
"export chat.md",
"how do I /export this?",
"",
] {
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);
let Some(Err(msg)) = parse("/export \"\"", loc) else {
panic!("expected a report in {lang:?}");
};
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
assert!(
msg.contains("/export"),
"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}"
);
}
}
}
}