use crate::entities::attachment::{AttachMode, AttachmentInfo};
use crate::shared::i18n::Locale;
#[derive(Debug, Clone, PartialEq)]
pub enum FileCommand {
Attach { path: String },
Remove { target: String },
List,
Open { target: String },
Folder,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FileProgress {
Attached {
info: AttachmentInfo,
total_tokens: usize,
read_as: Option<&'static str>,
},
Removed {
name: String,
source: Option<String>,
},
RemovedStored { name: String },
RemovedPair { name: String },
Saved { names: Vec<String>, dir: String },
StoredFile {
name: String,
bytes: u64,
mime: String,
dir: String,
},
Listed {
items: Vec<AttachmentInfo>,
stored: Vec<StoredInfo>,
images: Vec<crate::entities::message_image::ImageInfo>,
dir: String,
},
Opened { name: String, path: String },
OpenedFolder {
path: String,
instead_of: Option<OpenedInstead>,
},
Indexing {
name: String,
done: usize,
total: usize,
},
Indexed { name: String, chunks: usize },
IndexSkipped { name: String, reason: String },
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct OpenedInstead {
pub name: String,
pub by_a_call: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StoredInfo {
pub name: String,
pub bytes: u64,
pub mime: String,
pub missing: bool,
}
pub fn parse(input: &str, loc: &Locale) -> Option<Result<FileCommand, String>> {
let mut tokens = input.split_whitespace();
let first = tokens.next()?;
if !first.eq_ignore_ascii_case("/file") {
return None;
}
let Some(sub) = tokens.next() else {
return Some(Err(loc.tf(
"ui.file.err.missing_subcommand",
&[("usage", loc.t("ui.file.usage"))],
)));
};
let rest: Vec<&str> = tokens.collect();
if sub.eq_ignore_ascii_case("attach") {
match argument(&rest) {
Some(path) => Some(Ok(FileCommand::Attach { path })),
None => Some(Err(loc.tf(
"ui.file.err.missing_path",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("remove") {
match argument(&rest) {
Some(target) => Some(Ok(FileCommand::Remove { target })),
None => Some(Err(loc.tf(
"ui.file.err.missing_target",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("list") {
Some(Ok(FileCommand::List))
} else if sub.eq_ignore_ascii_case("open") {
match argument(&rest) {
Some(target) => Some(Ok(FileCommand::Open { target })),
None => Some(Err(loc.tf(
"ui.file.err.missing_target",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("folder") {
Some(Ok(FileCommand::Folder))
} else {
Some(Err(loc.tf(
"ui.file.err.unknown_subcommand",
&[("sub", sub), ("usage", loc.t("ui.file.usage"))],
)))
}
}
fn argument(tokens: &[&str]) -> Option<String> {
let joined = tokens.join(" ");
let arg = joined.trim().trim_matches(|c| c == '"' || c == '\'').trim();
(!arg.is_empty()).then(|| arg.to_string())
}
pub fn mode_label(mode: AttachMode, loc: &'static Locale) -> &'static str {
match mode {
AttachMode::Inline => loc.t("ui.file.mode.inline"),
AttachMode::ByReference => loc.t("ui.file.mode.by_reference"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn attach(path: &str) -> Option<Result<FileCommand, String>> {
Some(Ok(FileCommand::Attach { path: path.into() }))
}
#[test]
fn non_file_input_is_none() {
assert_eq!(parse("regular message", ru()), None);
assert_eq!(parse("/rag add x", ru()), None);
assert_eq!(parse("", ru()), None);
}
#[test]
fn parses_attach_with_path() {
assert_eq!(
parse("/file attach d:\\dir\\notes.md", ru()),
attach("d:\\dir\\notes.md")
);
}
#[test]
fn path_with_spaces_and_quotes() {
assert_eq!(
parse("/file attach C:\\Program Files\\a.txt", ru()),
attach("C:\\Program Files\\a.txt")
);
assert_eq!(
parse("/file attach \"d:\\my docs\\a.txt\"", ru()),
attach("d:\\my docs\\a.txt")
);
}
#[test]
fn command_is_case_insensitive() {
assert_eq!(parse("/FILE ATTACH d:\\x.txt", ru()), attach("d:\\x.txt"));
assert_eq!(parse("/File List", ru()), Some(Ok(FileCommand::List)));
}
#[test]
fn parses_remove_and_list() {
assert_eq!(
parse("/file remove notes.md", ru()),
Some(Ok(FileCommand::Remove {
target: "notes.md".into()
}))
);
assert_eq!(
parse("/file remove #2", ru()),
Some(Ok(FileCommand::Remove {
target: "#2".into()
}))
);
assert_eq!(parse("/file list", ru()), Some(Ok(FileCommand::List)));
assert_eq!(
parse("/file list everything", ru()),
Some(Ok(FileCommand::List))
);
}
#[test]
fn parses_open_and_folder() {
assert_eq!(
parse("/file open #3", ru()),
Some(Ok(FileCommand::Open {
target: "#3".into()
}))
);
assert_eq!(
parse("/FILE Open \"my chart.png\"", ru()),
Some(Ok(FileCommand::Open {
target: "my chart.png".into()
}))
);
assert_eq!(parse("/file folder", ru()), Some(Ok(FileCommand::Folder)));
assert!(matches!(parse("/file open", ru()), Some(Err(_))));
}
#[test]
fn delete_is_not_a_command() {
assert!(matches!(parse("/file delete a.txt", ru()), Some(Err(_))));
}
#[test]
fn errors_on_missing_parts() {
assert!(matches!(parse("/file", ru()), Some(Err(_))));
assert!(matches!(parse("/file attach", ru()), Some(Err(_))));
assert!(matches!(parse("/file remove", ru()), Some(Err(_))));
assert!(matches!(parse("/file purge x", ru()), Some(Err(_))));
}
#[test]
fn errors_are_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for input in [
"/file",
"/file attach",
"/file remove",
"/file open",
"/file purge x",
] {
let Some(Err(msg)) = parse(input, loc) else {
panic!("expected a syntax error for {input:?} in {lang:?}");
};
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
if lang == crate::shared::i18n::Lang::En {
assert!(
!msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {msg}"
);
}
}
for mode in [AttachMode::Inline, AttachMode::ByReference] {
assert!(!mode_label(mode, loc).is_empty());
}
}
}
}