use std::path::Path;
use crate::entities::attachment::{Attachment, Resolved, resolve_handle_by};
use crate::entities::chat_file::{ChatFile, mime_for, sanitize_name, versioned};
use crate::entities::message_image::MessageImage;
#[derive(Debug, Clone, PartialEq)]
pub struct ChatInput {
pub id: uuid::Uuid,
pub handle: usize,
pub name: String,
pub source: String,
pub staged: String,
pub bytes: u64,
pub mime: String,
pub attachment: Option<usize>,
pub file: Option<String>,
pub image: Option<usize>,
}
impl ChatInput {
pub fn matches(&self, target: &str) -> bool {
let t = target.trim().trim_matches(|c| c == '"' || c == '\'');
let same = crate::entities::chat_file::same_name;
same(&self.name, t) || same(&self.source, t)
}
pub fn is_image(&self) -> bool {
self.image.is_some()
}
}
pub fn items(
attachments: &[Attachment],
files: &[ChatFile],
images: &[&MessageImage],
dir: &Path,
) -> Vec<ChatInput> {
let mut items = derive(attachments, files, images, dir);
number(&mut items);
items
}
pub fn reconcile(
previous: &[ChatInput],
attachments: &[Attachment],
files: &[ChatFile],
images: &[&MessageImage],
dir: &Path,
) -> Vec<ChatInput> {
let mut out = derive(attachments, files, images, dir);
let mut taken: Vec<String> = Vec::new();
let mut next = previous.iter().map(|p| p.handle).max().unwrap_or(0);
for item in out.iter_mut() {
if let Some(p) = previous.iter().find(|p| p.id == item.id) {
item.handle = p.handle;
item.staged = p.staged.clone();
taken.push(p.staged.to_lowercase());
}
}
for item in out.iter_mut().filter(|i| i.handle == 0) {
next += 1;
item.handle = next;
item.staged = unique_staged(&item.staged, next, &mut taken);
}
out.sort_by_key(|i| i.handle);
out
}
fn derive(
attachments: &[Attachment],
files: &[ChatFile],
images: &[&MessageImage],
dir: &Path,
) -> Vec<ChatInput> {
let mut items: Vec<ChatInput> = Vec::new();
for (i, a) in attachments.iter().enumerate() {
let linked = a.file_id.and_then(|id| files.iter().find(|f| f.id == id));
items.push(ChatInput {
id: a.id,
handle: 0,
name: a.name.clone(),
source: a.source.clone(),
staged: match linked {
Some(f) => f.name.clone(),
None => text_name(&a.name),
},
bytes: linked.map_or(a.bytes as u64, |f| f.bytes),
mime: linked.map_or_else(|| text_mime(&a.name).to_string(), |f| f.mime.clone()),
attachment: Some(i),
file: linked.map(|f| f.name.clone()),
image: None,
});
}
let linked: Vec<uuid::Uuid> = attachments.iter().filter_map(|a| a.file_id).collect();
for f in files.iter().filter(|f| !linked.contains(&f.id)) {
items.push(ChatInput {
id: f.id,
handle: 0,
name: f.name.clone(),
source: dir.join(&f.name).display().to_string(),
staged: f.name.clone(),
bytes: f.bytes,
mime: f.mime.clone(),
attachment: None,
file: Some(f.name.clone()),
image: None,
});
}
for (i, im) in images.iter().enumerate() {
items.push(ChatInput {
id: im.id,
handle: 0,
name: im.name.clone(),
source: im.source.clone(),
staged: image_name(&im.name, &im.mime),
bytes: im.bytes as u64,
mime: im.mime.clone(),
attachment: None,
file: None,
image: Some(i),
});
}
items
}
pub fn resolve(items: &[ChatInput], target: &str) -> Resolved {
resolve_handle_by(items, target, ChatInput::matches, |n| {
items.iter().position(|i| i.handle == n)
})
}
pub fn open_path(item: &ChatInput, dir: &Path) -> Option<std::path::PathBuf> {
match &item.file {
Some(name) => crate::features::chat_files::confined(dir, name).ok(),
None => Some(std::path::PathBuf::from(&item.source)),
}
}
pub const MAX_INPUT_FILES: usize = 20;
pub const MAX_INPUT_BYTES: u64 = 100 * 1024 * 1024;
pub fn over_input_cap(count: usize, bytes: u64) -> bool {
count > MAX_INPUT_FILES || bytes > MAX_INPUT_BYTES
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmInputs {
pub files: Vec<ConfirmFile>,
pub net: bool,
pub over_cap: Option<(usize, u64)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmFile {
pub handle: String,
pub resolved: Option<(String, u64)>,
}
pub fn for_confirm(items: &[ChatInput], handles: &[String], net: bool) -> ConfirmInputs {
let mut reached: Vec<usize> = Vec::new();
let files = handles
.iter()
.map(|handle| ConfirmFile {
handle: handle.trim().to_string(),
resolved: match resolve(items, handle) {
Resolved::One(at) => {
if !reached.contains(&at) {
reached.push(at);
}
Some((items[at].name.clone(), items[at].bytes))
}
Resolved::Shared(_) | Resolved::Nothing => None,
},
})
.collect();
let bytes: u64 = reached.iter().map(|&at| items[at].bytes).sum();
let over_cap = over_input_cap(reached.len(), bytes).then_some((reached.len(), bytes));
ConfirmInputs {
files,
net,
over_cap,
}
}
pub fn named_files(args: &serde_json::Value) -> NamedFiles {
use serde_json::Value;
let Some(value) = args.get("files") else {
return NamedFiles::Named(Vec::new());
};
match value {
Value::Null => NamedFiles::Named(Vec::new()),
Value::String(one) if !one.trim().is_empty() => NamedFiles::Named(vec![one.clone()]),
Value::String(_) => NamedFiles::Named(Vec::new()),
Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
match item.as_str() {
None => return NamedFiles::Malformed,
Some(handle) if handle.trim().is_empty() => continue,
Some(handle) => out.push(handle.to_string()),
}
}
NamedFiles::Named(out)
}
_ => NamedFiles::Malformed,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NamedFiles {
Named(Vec<String>),
Malformed,
}
fn number(items: &mut [ChatInput]) {
let mut taken: Vec<String> = Vec::new();
for (i, item) in items.iter_mut().enumerate() {
item.handle = i + 1;
item.staged = unique_staged(&item.staged, item.handle, &mut taken);
}
}
fn unique_staged(raw: &str, handle: usize, taken: &mut Vec<String>) -> String {
let base = sanitize_name(raw).unwrap_or_else(|| format!("file-{handle}"));
let name = (1..)
.map(|n| versioned(&base, n))
.find(|candidate| !taken.contains(&candidate.to_lowercase()))
.unwrap_or(base);
taken.push(name.to_lowercase());
name
}
fn text_name(name: &str) -> String {
match extension(name) {
Some(ext) if EXTRACTED.contains(&ext.as_str()) => format!("{name}.txt"),
Some(_) => name.to_string(),
None => format!("{name}.txt"),
}
}
fn text_mime(name: &str) -> &'static str {
match mime_for(name, b"") {
"application/octet-stream" => "text/plain",
mime => mime,
}
}
fn image_name(name: &str, mime: &str) -> String {
let ext = match mime {
"image/jpeg" => "jpg",
_ => "png",
};
match extension(name) {
Some(have) if have == ext || (ext == "jpg" && have == "jpeg") => name.to_string(),
_ => format!("{name}.{ext}"),
}
}
fn extension(name: &str) -> Option<String> {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
let (stem, ext) = base.rsplit_once('.')?;
(!stem.is_empty() && !ext.is_empty()).then(|| ext.to_ascii_lowercase())
}
const EXTRACTED: &[&str] = &["pdf", "docx", "html", "htm"];
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::attachment::AttachMode;
use crate::entities::chat_file::FileOrigin;
fn attached(name: &str, source: &str) -> Attachment {
Attachment::new(name, source, "text".into(), 4, AttachMode::Inline)
}
fn stored(name: &str) -> ChatFile {
ChatFile::new(name, FileOrigin::Sandbox, b"bytes")
}
fn stored_pdf(name: &str) -> ChatFile {
ChatFile::new(
name,
FileOrigin::Attached,
b"%PDF-1.7\nnot-really-a-document",
)
}
fn image(name: &str, mime: &str) -> MessageImage {
MessageImage::new(
name,
format!("C:\\pics\\{name}"),
mime,
10,
10,
"AAAA".into(),
)
}
fn shown(images: &[MessageImage]) -> Vec<&MessageImage> {
images.iter().collect()
}
fn dir() -> &'static Path {
Path::new("C:\\data\\files\\chat")
}
#[test]
fn a_handle_opens_our_copy_when_there_is_one_and_the_source_otherwise() {
let shot = [image("shot.png", "image/png")];
let original = stored_pdf("report.pdf");
let pair = attached("report.pdf", "D:\\downloads\\report.pdf").with_file(original.id);
let list = items(
&[pair, attached("notes.md", "C:\\notes.md")],
&[original, stored("chart.png")],
&shown(&shot),
dir(),
);
assert_eq!(open_path(&list[0], dir()), Some(dir().join("report.pdf")));
assert_eq!(
open_path(&list[1], dir()).as_deref(),
Some(Path::new("C:\\notes.md"))
);
assert_eq!(open_path(&list[2], dir()), Some(dir().join("chart.png")));
assert_eq!(
open_path(&list[3], dir()).as_deref(),
Some(Path::new("C:\\pics\\shot.png"))
);
}
#[test]
fn a_stored_name_that_leaves_the_folder_opens_nothing() {
for name in [
"../../escape.pdf",
"..\\escape.pdf",
"C:escape.pdf",
"..",
"",
] {
let list = items(&[], &[stored(name)], &[], dir());
assert_eq!(open_path(&list[0], dir()), None, "{name:?}");
}
}
#[test]
fn the_three_kinds_are_numbered_in_one_list() {
let shot = [image("shot.png", "image/png")];
let list = items(
&[attached("notes.md", "C:\\notes.md")],
&[stored("chart.png")],
&shown(&shot),
dir(),
);
let handles: Vec<(usize, &str)> =
list.iter().map(|i| (i.handle, i.staged.as_str())).collect();
assert_eq!(
handles,
vec![(1, "notes.md"), (2, "chart.png"), (3, "shot.png")]
);
assert!(list[0].attachment.is_some() && list[0].file.is_none());
assert_eq!(list[1].file.as_deref(), Some("chart.png"));
assert!(list[2].is_image());
assert!(list[1].source.ends_with("chart.png"));
}
#[test]
fn an_attached_document_and_its_original_are_one_item_that_stages_the_original() {
let original = stored_pdf("report.pdf");
let mut att = attached("report.pdf", "C:\\report.pdf");
att.file_id = Some(original.id);
let list = items(&[att], &[original], &[], dir());
assert_eq!(list.len(), 1, "the pair is one item: {list:?}");
assert!(list[0].attachment.is_some() && list[0].file.is_some());
assert_eq!(list[0].staged, "report.pdf");
assert_eq!(list[0].mime, "application/pdf");
}
#[test]
fn an_extracted_document_without_its_original_is_staged_as_text() {
let list = items(&[attached("report.pdf", "C:\\report.pdf")], &[], &[], dir());
assert_eq!(list[0].staged, "report.pdf.txt");
assert_eq!(list[0].mime, "text/plain");
}
#[test]
fn a_source_file_keeps_its_name_and_a_nameless_attachment_gains_txt() {
let list = items(
&[
attached("main.rs", "C:\\src\\main.rs"),
attached("Rust by Example", "https://example.com/page"),
],
&[],
&[],
dir(),
);
assert_eq!(list[0].staged, "main.rs");
assert_eq!(list[1].staged, "Rust by Example.txt");
assert_eq!(list[0].mime, "text/plain");
}
#[test]
fn a_name_two_items_share_is_versioned_once_for_the_guest() {
let list = items(
&[
attached("notes.md", "C:\\a\\notes.md"),
attached("notes.md", "C:\\b\\notes.md"),
],
&[stored("notes.md")],
&[],
dir(),
);
let staged: Vec<&str> = list.iter().map(|i| i.staged.as_str()).collect();
assert_eq!(staged, vec!["notes.md", "notes (2).md", "notes (3).md"]);
}
#[test]
fn a_name_that_survives_nothing_falls_back_to_its_handle() {
let mut crafted = items(&[attached("notes.md", "C:\\a\\notes.md")], &[], &[], dir());
crafted[0].staged = "..".into();
number(&mut crafted);
assert_eq!(crafted[0].staged, "file-1");
}
#[test]
fn an_image_is_staged_under_the_format_it_was_prepared_into() {
let pictures = [
image("shot.png", "image/png"),
image("photo.heic", "image/jpeg"),
image("scan.JPEG", "image/jpeg"),
];
let list = items(&[], &[], &shown(&pictures), dir());
let staged: Vec<&str> = list.iter().map(|i| i.staged.as_str()).collect();
assert_eq!(staged, vec!["shot.png", "photo.heic.jpg", "scan.JPEG"]);
}
#[test]
fn a_handle_resolves_by_number_name_or_path_and_a_shared_name_refuses() {
let list = items(
&[
attached("notes.md", "C:\\a\\notes.md"),
attached("notes.md", "C:\\b\\notes.md"),
],
&[stored("chart.png")],
&[],
dir(),
);
assert_eq!(resolve(&list, "#3"), Resolved::One(2));
assert_eq!(resolve(&list, "chart.png"), Resolved::One(2));
assert_eq!(resolve(&list, "C:\\b\\notes.md"), Resolved::One(1));
assert_eq!(resolve(&list, "notes.md"), Resolved::Shared(vec![0, 1]));
assert_eq!(resolve(&list, "#9"), Resolved::Nothing);
assert_eq!(resolve(&list, "nothing.csv"), Resolved::Nothing);
assert_eq!(resolve(&list, "notes (2).md"), Resolved::Nothing);
}
#[test]
fn a_bare_number_reaches_the_carried_handle_not_the_position() {
let before = items(
&[
attached("gone.csv", "C:\\gone.csv"),
attached("notes.md", "C:\\notes.md"),
],
&[],
&[],
dir(),
);
let now = [
attached("notes.md", "C:\\notes.md"),
attached("new.csv", "C:\\new.csv"),
];
let mut now = now.to_vec();
now[0].id = before[1].id;
let after = reconcile(&before, &now, &[], &[], dir());
assert_eq!(after[0].handle, 2);
assert_eq!(resolve(&after, "2"), Resolved::One(0));
assert_eq!(resolve(&after, "3"), Resolved::One(1));
assert_eq!(resolve(&after, "1"), Resolved::Nothing, "#1 left the chat");
}
#[test]
fn the_popup_counts_a_call_over_the_cap_as_the_refusal_does() {
let files: Vec<ChatFile> = (1..=MAX_INPUT_FILES + 1)
.map(|n| stored(&format!("f{n}.csv")))
.collect();
let list = items(&[], &files, &[], dir());
let handles = |n: usize| (1..=n).map(|i| format!("#{i}")).collect::<Vec<_>>();
assert_eq!(
for_confirm(&list, &handles(MAX_INPUT_FILES), false).over_cap,
None
);
let mut twice = handles(MAX_INPUT_FILES);
twice.push("#1".into());
assert_eq!(
for_confirm(&list, &twice, false).over_cap,
None,
"a handle named twice is one file"
);
let over = for_confirm(&list, &handles(MAX_INPUT_FILES + 1), false);
let bytes: u64 = list.iter().map(|item| item.bytes).sum();
assert_eq!(over.over_cap, Some((MAX_INPUT_FILES + 1, bytes)));
assert_eq!(
over.files.len(),
MAX_INPUT_FILES + 1,
"every handle is still listed"
);
let mut big = [stored("a.bin"), stored("b.bin")];
for file in &mut big {
file.bytes = MAX_INPUT_BYTES / 2 + 1;
}
let list = items(&[], &big, &[], dir());
assert_eq!(
for_confirm(&list, &handles(2), false).over_cap,
Some((2, MAX_INPUT_BYTES + 2)),
"two files over the size"
);
}
#[test]
fn a_handle_folds_case_outside_ascii_too() {
let list = items(
&[attached("Отчёт.csv", "C:\\a\\Отчёт.csv")],
&[stored("Диаграмма.png")],
&[],
dir(),
);
assert_eq!(resolve(&list, "отчёт.csv"), Resolved::One(0));
assert_eq!(resolve(&list, "ДИАГРАММА.PNG"), Resolved::One(1));
assert_eq!(resolve(&list, "c:\\a\\отчёт.csv"), Resolved::One(0));
let shared = items(
&[
attached("Отчёт.csv", "C:\\a\\Отчёт.csv"),
attached("отчёт.csv", "C:\\b\\отчёт.csv"),
],
&[],
&[],
dir(),
);
assert_eq!(resolve(&shared, "отчёт.csv"), Resolved::Shared(vec![0, 1]));
}
#[test]
fn a_number_the_block_promised_survives_what_lands_mid_turn() {
let shot = [image("shot.png", "image/png")];
let notes = attached("notes.md", "C:\\notes.md");
let sales = stored("sales.csv");
let block = items(
std::slice::from_ref(¬es),
std::slice::from_ref(&sales),
&shown(&shot),
dir(),
);
assert_eq!(
(block[0].handle, block[1].handle, block[2].handle),
(1, 2, 3)
);
let page = attached("A page", "https://example.com/a");
let after = reconcile(
&block,
&[notes.clone(), page.clone()],
std::slice::from_ref(&sales),
&shown(&shot),
dir(),
);
let by_handle = |n: usize| {
let Resolved::One(at) = resolve(&after, &format!("#{n}")) else {
panic!("#{n} resolves to nothing");
};
after[at].name.clone()
};
assert_eq!(by_handle(1), "notes.md");
assert_eq!(by_handle(2), "sales.csv", "a stored file must not slide");
assert_eq!(by_handle(3), "shot.png", "an image must not slide either");
assert_eq!(
by_handle(4),
"A page",
"the newcomer is numbered after them"
);
}
#[test]
fn a_staged_name_the_block_promised_survives_too() {
let a = attached("notes.md", "C:\\a\\notes.md");
let b = attached("notes.md", "C:\\b\\notes.md");
let block = items(&[a.clone(), b.clone()], &[], &[], dir());
assert_eq!(block[0].staged, "notes.md");
assert_eq!(block[1].staged, "notes (2).md");
let c = attached("notes.md", "C:\\c\\notes.md");
let after = reconcile(&block, &[a, c, b], &[], &[], dir());
let staged = |source: &str| {
let Resolved::One(at) = resolve(&after, source) else {
panic!("{source} resolves to nothing");
};
after[at].staged.clone()
};
assert_eq!(staged("C:\\a\\notes.md"), "notes.md");
assert_eq!(staged("C:\\b\\notes.md"), "notes (2).md");
assert_eq!(
staged("C:\\c\\notes.md"),
"notes (3).md",
"the newcomer takes a name neither of the promised two holds"
);
}
#[test]
fn an_attachment_that_moved_keeps_the_number_it_was_given() {
let first = attached("notes.md", "C:\\notes.md");
let page = attached("A page", "https://example.com/a");
let block = items(&[first.clone(), page.clone()], &[], &[], dir());
assert_eq!(block[0].name, "notes.md");
let after = reconcile(&block, &[page, first], &[], &[], dir());
let Resolved::One(at) = resolve(&after, "#1") else {
panic!("#1 resolves to nothing");
};
assert_eq!(after[at].name, "notes.md");
}
#[test]
fn the_files_argument_is_read_once_and_says_when_it_cannot_be() {
use serde_json::json;
let named = |v: serde_json::Value| named_files(&v);
assert_eq!(named(json!({"code": "x"})), NamedFiles::Named(vec![]));
assert_eq!(named(json!({"files": null})), NamedFiles::Named(vec![]));
assert_eq!(named(json!({"files": []})), NamedFiles::Named(vec![]));
assert_eq!(named(json!({"files": " "})), NamedFiles::Named(vec![]));
assert_eq!(
named(json!({"files": ["sales.csv", "#2"]})),
NamedFiles::Named(vec!["sales.csv".into(), "#2".into()])
);
assert_eq!(
named(json!({"files": "sales.csv"})),
NamedFiles::Named(vec!["sales.csv".into()])
);
assert_eq!(
named(json!({"files": ["sales.csv", "", " "]})),
NamedFiles::Named(vec!["sales.csv".into()])
);
assert_eq!(named(json!({"files": ["a", 3]})), NamedFiles::Malformed);
assert_eq!(named(json!({"files": 7})), NamedFiles::Malformed);
assert_eq!(named(json!({"files": {"a": 1}})), NamedFiles::Malformed);
}
#[test]
fn a_number_is_never_reused_by_a_newcomer() {
let notes = attached("notes.md", "C:\\notes.md");
let sales = stored("sales.csv");
let block = items(
std::slice::from_ref(¬es),
std::slice::from_ref(&sales),
&[],
dir(),
);
assert_eq!(block[1].handle, 2);
let after = reconcile(
&block,
std::slice::from_ref(¬es),
&[stored("chart.png")],
&[],
dir(),
);
assert_eq!(resolve(&after, "#2"), Resolved::Nothing);
let Resolved::One(at) = resolve(&after, "#3") else {
panic!("#3 resolves to nothing");
};
assert_eq!(after[at].name, "chart.png");
}
}