use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::app::events::AppEvent;
use crate::entities::attachment::{
AttachMode, Attachment, AttachmentChunk, Resolved, decide_mode, handle_number, handle_range,
inline_tokens_excluding, name_is_shared, prompt_tokens,
};
use crate::entities::chat_file::{ChatFile, FileOrigin};
use crate::features::file_command::{FileProgress, OpenedInstead, StoredInfo};
use crate::features::tools::rag::ChunkParams;
use crate::shared::api::{EmbedRole, Embedder};
use crate::shared::i18n::Locale;
use crate::shared::storage::Storage;
use super::Orchestrator;
use super::rag::{EMBED_BATCH_CHUNKS, is_markdown_source};
const MAX_ATTACH_BYTES: u64 = 32 * 1024 * 1024;
pub(super) struct AttachResult {
pub(super) chat_id: Uuid,
pub(super) outcome: Result<ExtractedFile, String>,
pub(super) stored: Option<Result<crate::features::chat_files::Stored, String>>,
}
impl AttachResult {
pub(super) fn prepare(
chat_id: Uuid,
mut outcome: Result<ExtractedFile, String>,
dir: &std::path::Path,
listed: &[ChatFile],
loc: &'static Locale,
) -> Self {
let stored = match &mut outcome {
Ok(file) => file
.original
.take()
.map(|bytes| store_original(dir, listed, file, &bytes, loc)),
Err(_) => None,
};
Self {
chat_id,
outcome,
stored,
}
}
}
fn store_original(
dir: &std::path::Path,
listed: &[ChatFile],
file: &ExtractedFile,
bytes: &[u8],
loc: &'static Locale,
) -> Result<crate::features::chat_files::Stored, String> {
let name =
crate::entities::chat_file::sanitize_name(&file.name).unwrap_or_else(|| "file".to_string());
let zone = crate::shared::os_open::zone_of(std::path::Path::new(&file.source));
crate::features::chat_files::store_as(
dir,
listed,
&name,
bytes,
FileOrigin::Attached,
zone.as_deref(),
)
.map_err(|e| {
loc.tf(
"ui.err.file_store_failed",
&[("name", &name), ("err", &e.to_string())],
)
})
}
pub(super) struct OpenResult {
pub(super) chat_id: Uuid,
pub(super) progress: FileProgress,
}
#[derive(Debug)]
pub(super) struct ExtractedFile {
pub(super) name: String,
pub(super) source: String,
pub(super) text: String,
pub(super) bytes: usize,
pub(super) encoding: Option<&'static encoding_rs::Encoding>,
pub(super) original: Option<Vec<u8>>,
}
impl Orchestrator {
pub(super) fn handle_file_attach(&mut self, path: String) {
let path = path.trim().to_string();
if path.is_empty() {
return;
}
let Some(chat_id) = self.active_id else {
self.fail_file(self.ui_locale().t("ui.err.file_no_active_chat"));
return;
};
let loc = self.ui_locale();
let hint = crate::shared::text_decode::tld_hint(self.config.interface.language);
let tx = self.attach_tx.clone();
let (dir, listed) = self.attach_snapshot(chat_id);
tokio::task::spawn_blocking(move || {
let outcome = extract_file(std::path::Path::new(&path), loc, hint);
let _ = tx.send(AttachResult::prepare(chat_id, outcome, &dir, &listed, loc));
});
}
pub(super) fn attach_snapshot(&self, chat_id: Uuid) -> (std::path::PathBuf, Vec<ChatFile>) {
let listed = self
.chats
.iter()
.find(|c| c.id == chat_id)
.map(|c| c.files.clone())
.unwrap_or_default();
(self.stored_files_dir(chat_id), listed)
}
pub(super) fn handle_attach_result(&mut self, res: AttachResult) {
let file = match res.outcome {
Ok(f) => f,
Err(err) => {
self.fail_file(&err);
return;
}
};
let stored = match res.stored {
Some(Ok(stored)) => match self.land_original(res.chat_id, stored) {
Some(listed) => Some(listed),
None => {
let msg = self.ui_locale().tf(
"ui.err.file_removed_while_attaching",
&[("name", &file.name)],
);
self.fail_file(&msg);
return;
}
},
Some(Err(msg)) => {
self.fail_file(&msg);
return;
}
None => None,
};
if file.text.trim().is_empty() {
if let Some(stored) = stored {
let dir = self.stored_files_dir(res.chat_id).display().to_string();
self.emit_file_progress(FileProgress::StoredFile {
name: stored.name,
bytes: stored.bytes,
mime: stored.mime,
dir,
});
}
return;
}
let cfg = self.config.attachments;
let previous = self
.chats
.iter()
.find(|c| c.id == res.chat_id)
.and_then(|c| c.attachments.iter().find(|a| a.source == file.source))
.and_then(|a| a.file_id);
let Some(chat) = self.chat_mut(res.chat_id) else {
return; };
let est = crate::shared::tokens::estimate_text(&file.text) as usize;
let used = inline_tokens_excluding(&chat.attachments, &file.source);
let mode = decide_mode(est, used, &cfg);
let read_as = file.encoding.map(encoding_rs::Encoding::name);
let mut attachment = Attachment::new(file.name, file.source, file.text, file.bytes, mode);
if let Some(stored) = &stored {
attachment = attachment.with_file(stored.id);
}
self.insert_attachment(res.chat_id, attachment, read_as);
if let Some(old) = previous.filter(|old| stored.as_ref().is_none_or(|s| s.id != *old)) {
self.drop_original(res.chat_id, old);
}
}
fn land_original(
&mut self,
chat_id: Uuid,
stored: crate::features::chat_files::Stored,
) -> Option<ChatFile> {
use crate::features::chat_files::Stored;
match stored {
Stored::New(file) => {
if let Some(chat) = self.chat_mut(chat_id) {
chat.list_file(file.clone());
}
self.mark_dirty(chat_id);
Some(file)
}
Stored::Unchanged(file) | Stored::Restored(file) => {
let still_listed = self
.chats
.iter()
.find(|c| c.id == chat_id)
.is_none_or(|c| c.files.iter().any(|f| f.id == file.id));
still_listed.then_some(file)
}
}
}
fn drop_original(&mut self, chat_id: Uuid, file_id: Uuid) {
let dir = self.stored_files_dir(chat_id);
let Some(name) = self
.chats
.iter()
.find(|c| c.id == chat_id)
.and_then(|c| c.files.iter().find(|f| f.id == file_id))
.map(|f| f.name.clone())
else {
return;
};
if crate::features::chat_files::remove(&dir, &name).is_err() {
return; }
if let Some(chat) = self.chat_mut(chat_id) {
chat.files.retain(|f| f.id != file_id);
}
self.mark_dirty(chat_id);
}
pub(super) fn insert_attachment(
&mut self,
chat_id: Uuid,
attachment: Attachment,
read_as: Option<&'static str>,
) {
let cfg = self.config.attachments;
let Some(chat) = self.chat_mut(chat_id) else {
return; };
chat.attachments.retain(|a| a.source != attachment.source);
let info = attachment.info(cfg.excerpt_tokens);
let index = (attachment.mode == AttachMode::ByReference).then(|| AttachIndex {
chat_id,
attachment_id: attachment.id,
name: attachment.name.clone(),
source: attachment.source.clone(),
text: attachment.text.clone(),
});
chat.attachments.push(attachment);
let total = prompt_tokens(&chat.attachments, cfg.excerpt_tokens);
let keep: Vec<Uuid> = chat.attachments.iter().map(|a| a.id).collect();
self.mark_dirty(chat_id);
self.prune_attachment_index(chat_id, &keep);
self.emit_file_progress(FileProgress::Attached {
info,
total_tokens: total,
read_as,
});
self.emit_attachments();
if let Some(task) = index {
self.spawn_attachment_index(task);
}
}
fn spawn_attachment_index(&self, task: AttachIndex) {
spawn_attachment_index(AttachIndexTask {
embedder: self.engines.embedder(),
storage: self.storage.clone(),
params: ChunkParams::from_settings(&self.config.rag),
loc: self.ui_locale(),
evt_tx: self.evt_tx.clone(),
index: task,
});
}
fn prune_attachment_index(&self, chat_id: Uuid, keep: &[Uuid]) {
if let Err(err) = self.storage.db().attachment_prune(chat_id, keep) {
tracing::warn!(error = %err, "attachments: failed to prune the search index");
}
}
pub(super) fn handle_file_remove(&mut self, target: String) {
let Some(chat_id) = self.active_id else {
self.fail_file(self.ui_locale().t("ui.err.file_no_active_chat"));
return;
};
let loc = self.ui_locale();
let (items, dir) = self.chat_file_list(chat_id);
let Some(item) = self.resolve_file_handle(&items, &target) else {
return;
};
if item.is_image() {
let msg = loc.tf("ui.err.file_is_image", &[("name", &item.name)]);
self.fail_file(&msg);
return;
}
match (item.attachment, item.file.as_deref()) {
(Some(at), Some(file)) => self.remove_pair(chat_id, &dir, at, file, &item.name),
(Some(at), None) => {
let shared = name_is_shared(&items, item.handle - 1, |i| i.name.as_str());
let Some(chat) = self.chat_mut(chat_id) else {
return;
};
let removed = chat.attachments.remove(at);
let keep: Vec<Uuid> = chat.attachments.iter().map(|a| a.id).collect();
self.mark_dirty(chat_id);
self.prune_attachment_index(chat_id, &keep);
self.emit_file_progress(FileProgress::Removed {
name: removed.name,
source: shared.then_some(removed.source),
});
self.emit_attachments();
}
(None, Some(file)) => self.remove_stored_file(chat_id, &dir, file.to_string()),
(None, None) => {}
}
}
pub(super) fn chat_file_list(
&self,
chat_id: Uuid,
) -> (
Vec<crate::features::chat_inputs::ChatInput>,
std::path::PathBuf,
) {
let dir = self.stored_files_dir(chat_id);
let Some(chat) = self.chats.iter().find(|c| c.id == chat_id) else {
return (Vec::new(), dir);
};
let images: Vec<&crate::entities::message_image::MessageImage> =
chat.messages.iter().flat_map(|m| m.images.iter()).collect();
let items =
crate::features::chat_inputs::items(&chat.attachments, &chat.files, &images, &dir);
(items, dir)
}
fn remove_pair(
&mut self,
chat_id: Uuid,
dir: &std::path::Path,
attachment_at: usize,
file: &str,
name: &str,
) {
if let Err(err) = crate::features::chat_files::remove(dir, file) {
let msg = self.ui_locale().tf(
"ui.err.file_delete_failed",
&[("name", file), ("err", &err.to_string())],
);
self.fail_file(&msg);
return;
}
let mut keep = Vec::new();
if let Some(chat) = self.chat_mut(chat_id) {
chat.files.retain(|f| f.name != file);
chat.attachments.remove(attachment_at);
keep = chat.attachments.iter().map(|a| a.id).collect();
}
self.mark_dirty(chat_id);
self.prune_attachment_index(chat_id, &keep);
self.emit_file_progress(FileProgress::RemovedPair { name: name.into() });
self.emit_attachments();
}
pub(super) fn candidate_lines<'a>(
candidates: impl Iterator<Item = (usize, &'a str)>,
) -> String {
candidates
.map(|(i, source)| format!("\n• #{} {source}", i + 1))
.collect()
}
fn remove_stored_file(&mut self, chat_id: Uuid, dir: &std::path::Path, name: String) {
if let Err(err) = crate::features::chat_files::remove(dir, &name) {
let msg = self.ui_locale().tf(
"ui.err.file_delete_failed",
&[("name", &name), ("err", &err.to_string())],
);
self.fail_file(&msg);
return;
}
if let Some(chat) = self.chat_mut(chat_id) {
chat.files.retain(|f| f.name != name);
}
self.mark_dirty(chat_id);
self.emit_file_progress(FileProgress::RemovedStored { name });
}
pub(super) fn handle_file_list(&mut self) {
let Some(chat) = self
.active_id
.and_then(|id| self.chats.iter().find(|c| c.id == id))
else {
self.fail_file(self.ui_locale().t("ui.err.file_no_active_chat"));
return;
};
let excerpt = self.config.attachments.excerpt_tokens;
let items = chat.attachments.iter().map(|a| a.info(excerpt)).collect();
let dir = self.stored_files_dir(chat.id);
let linked: Vec<Uuid> = chat.attachments.iter().filter_map(|a| a.file_id).collect();
let stored = chat
.files
.iter()
.filter(|f| !linked.contains(&f.id))
.map(|f| StoredInfo {
name: f.name.clone(),
bytes: f.bytes,
mime: f.mime.clone(),
missing: !crate::features::chat_files::exists(&dir, &f.name),
})
.collect();
let images = chat
.messages
.iter()
.flat_map(|m| m.images.iter())
.map(crate::entities::message_image::ImageInfo::from)
.collect();
self.emit_file_progress(FileProgress::Listed {
items,
stored,
images,
dir: dir.display().to_string(),
});
}
pub(super) fn handle_file_open(&mut self, target: String) {
let Some((path, opened)) = self.plan_open(&target) else {
return;
};
if let Some(chat_id) = self.active_id {
self.spawn_open(chat_id, path, opened);
}
}
pub(super) fn plan_open(&self, target: &str) -> Option<(std::path::PathBuf, FileProgress)> {
let Some(chat_id) = self.active_id else {
self.fail_file(self.ui_locale().t("ui.err.file_no_active_chat"));
return None;
};
let (items, dir) = self.chat_file_list(chat_id);
let item = self.resolve_file_handle(&items, target)?;
let nothing_to_open = || {
let msg = self.ui_locale().tf(
"ui.err.file_nothing_to_open",
&[("name", &item.name), ("source", &item.source)],
);
self.fail_file(&msg);
};
let Some(path) =
crate::features::chat_inputs::open_path(&item, &dir).filter(|path| path.is_file())
else {
nothing_to_open();
return None;
};
let Some((opens, at)) = crate::shared::os_open::decide(&path) else {
nothing_to_open();
return None;
};
let progress = match opens {
crate::shared::os_open::Opens::File => FileProgress::Opened {
name: item.name.clone(),
path: at.display().to_string(),
},
crate::shared::os_open::Opens::Folder => FileProgress::OpenedFolder {
path: at.display().to_string(),
instead_of: Some(OpenedInstead {
name: item.name.clone(),
by_a_call: self.written_by_a_call(chat_id, item.id),
}),
},
};
Some((at.to_path_buf(), progress))
}
fn written_by_a_call(&self, chat_id: Uuid, id: Uuid) -> bool {
self.chats
.iter()
.find(|chat| chat.id == chat_id)
.and_then(|chat| chat.files.iter().find(|file| file.id == id))
.is_some_and(|file| matches!(file.origin, FileOrigin::Sandbox | FileOrigin::Recovered))
}
pub(super) fn handle_file_folder(&mut self) {
let Some(chat_id) = self.active_id else {
self.fail_file(self.ui_locale().t("ui.err.file_no_active_chat"));
return;
};
let dir = self.stored_files_dir(chat_id);
let path = dir.display().to_string();
if !dir.is_dir() {
let msg = self
.ui_locale()
.tf("ui.err.file_no_folder_yet", &[("path", &path)]);
self.fail_file(&msg);
return;
}
self.spawn_open(
chat_id,
dir,
FileProgress::OpenedFolder {
path,
instead_of: None,
},
);
}
fn resolve_file_handle(
&self,
items: &[crate::features::chat_inputs::ChatInput],
target: &str,
) -> Option<crate::features::chat_inputs::ChatInput> {
let loc = self.ui_locale();
match crate::features::chat_inputs::resolve(items, target) {
Resolved::One(at) => Some(items[at].clone()),
Resolved::Shared(hits) => {
let sources = hits.iter().map(|&i| (i, items[i].source.as_str()));
let candidates = Self::candidate_lines(sources);
let msg = loc.tf(
"ui.err.file_name_shared",
&[("target", target.trim()), ("candidates", &candidates)],
);
self.fail_file(&msg);
None
}
Resolved::Nothing => {
let target = target.trim();
let msg = match (items.len(), handle_number(target)) {
(0, _) => loc.tf("ui.err.file_none_in_chat", &[("target", target)]),
(count, Some(n)) => loc.tf(
"ui.err.file_no_such_number",
&[("n", &n.to_string()), ("range", &handle_range(count))],
),
(_, None) => loc.tf("ui.err.file_not_attached", &[("target", target)]),
};
self.fail_file(&msg);
None
}
}
}
fn spawn_open(&self, chat_id: Uuid, path: std::path::PathBuf, opened: FileProgress) {
let loc = self.ui_locale();
let tx = self.open_tx.clone();
tokio::task::spawn_blocking(move || {
let progress = match crate::shared::os_open::open(&path) {
Ok(()) => opened,
Err(err) => FileProgress::Failed(loc.tf(
"ui.err.file_open_failed",
&[
("path", &path.display().to_string()),
("err", &err.to_string()),
],
)),
};
let _ = tx.send(OpenResult { chat_id, progress });
});
}
pub(super) fn handle_open_result(&self, res: OpenResult) {
let addressed = self.active_id == Some(res.chat_id);
if addressed || matches!(res.progress, FileProgress::Failed(_)) {
self.emit_file_progress(res.progress);
}
}
pub(super) fn stored_files_dir(&self, chat_id: Uuid) -> std::path::PathBuf {
self.storage.json().files_dir().join(chat_id.to_string())
}
pub(super) fn list_stored_files(&mut self, chat_id: Uuid, files: Vec<ChatFile>) {
if files.is_empty() {
return;
}
let dir = self.stored_files_dir(chat_id);
let Some(chat) = self.chat_mut(chat_id) else {
return;
};
let names: Vec<String> = files
.into_iter()
.filter_map(|f| {
let name = f.name.clone();
chat.list_file(f).then_some(name)
})
.collect();
if names.is_empty() {
return;
}
self.mark_dirty(chat_id);
if self.active_id == Some(chat_id) {
self.emit_file_progress(FileProgress::Saved {
names,
dir: dir.display().to_string(),
});
}
}
pub(super) fn adopt_unlisted_files(&mut self) {
let root = self.storage.json().files_dir();
if !root.is_dir() {
return;
}
let mut adopted = Vec::new();
for chat in &mut self.chats {
let dir = root.join(chat.id.to_string());
let found = crate::features::chat_files::unlisted(&dir, &chat.files);
if found.is_empty() {
continue;
}
for file in &found {
crate::features::chat_files::mark(
&dir.join(&file.name),
Some(crate::shared::os_open::FROM_ELSEWHERE),
);
}
tracing::info!(
chat = %chat.id,
files = found.len(),
"stored files: adopted files the chat did not list"
);
chat.files.extend(found);
adopted.push(chat.id);
}
for id in adopted {
self.mark_dirty(id);
}
}
pub(super) fn emit_attachments(&self) {
let excerpt = self.config.attachments.excerpt_tokens;
let items = self
.active_id
.and_then(|id| self.chats.iter().find(|c| c.id == id))
.map(|c| c.attachments.iter().map(|a| a.info(excerpt)).collect())
.unwrap_or_default();
let _ = self.evt_tx.send(AppEvent::Attachments(items));
}
fn emit_file_progress(&self, progress: FileProgress) {
let _ = self.evt_tx.send(AppEvent::FileProgress(progress));
}
fn fail_file(&self, msg: &str) {
self.emit_file_progress(FileProgress::Failed(msg.to_string()));
}
}
pub(super) struct AttachIndex {
pub(super) chat_id: Uuid,
pub(super) attachment_id: Uuid,
pub(super) name: String,
pub(super) source: String,
pub(super) text: String,
}
struct AttachIndexTask {
embedder: Arc<dyn Embedder>,
storage: Arc<Storage>,
params: ChunkParams,
loc: &'static Locale,
evt_tx: tokio::sync::mpsc::UnboundedSender<AppEvent>,
index: AttachIndex,
}
pub(super) async fn index_attachment(
embedder: &Arc<dyn Embedder>,
storage: &Arc<Storage>,
params: ChunkParams,
index: &AttachIndex,
loc: &'static Locale,
cancel: &CancellationToken,
mut progress: impl FnMut(usize, usize),
) -> Result<usize, String> {
let chunks = if is_markdown_source(&index.source) {
crate::features::tools::rag::chunk_markdown(&index.text, params)
} else {
crate::features::tools::rag::chunk_text(&index.text, params)
};
if chunks.is_empty() {
return Ok(0);
}
storage
.db()
.attachment_delete(index.chat_id, index.attachment_id)
.map_err(|e| e.to_string())?;
let total = chunks.len();
progress(0, total);
let mut done = 0usize;
for batch in chunks.chunks(EMBED_BATCH_CHUNKS) {
if cancel.is_cancelled() {
return Ok(done);
}
let embeddings = match embedder.embed(batch.to_vec(), EmbedRole::Passage).await {
Ok(v) if v.len() == batch.len() => v,
Ok(_) => return Err(loc.t("ui.err.rag_wrong_vector_count").to_string()),
Err(err) => return Err(err.to_string()),
};
for (chunk, embedding) in batch.iter().zip(embeddings) {
let doc = AttachmentChunk::new(
index.chat_id,
index.attachment_id,
&index.name,
chunk,
embedding,
);
if let Err(err) = storage.db().attachment_insert(&doc) {
tracing::warn!(error = %err, name = %index.name, "attachments: indexing failed");
return Err(err.to_string());
}
}
done += batch.len();
progress(done, total);
}
Ok(done)
}
fn spawn_attachment_index(task: AttachIndexTask) {
let AttachIndexTask {
embedder,
storage,
params,
loc,
evt_tx,
index,
} = task;
tokio::spawn(async move {
let skip = |reason: String| {
let _ = evt_tx.send(AppEvent::FileProgress(FileProgress::IndexSkipped {
name: index.name.clone(),
reason,
}));
};
if embedder
.embed(vec!["ping".into()], EmbedRole::Passage)
.await
.is_err()
{
skip(loc.t("ui.file.index_no_embedder").to_string());
return;
}
let cancel = CancellationToken::new();
let progress_tx = evt_tx.clone();
let name = index.name.clone();
let outcome = index_attachment(
&embedder,
&storage,
params,
&index,
loc,
&cancel,
|done, total| {
let _ = progress_tx.send(AppEvent::FileProgress(FileProgress::Indexing {
name: name.clone(),
done,
total,
}));
},
)
.await;
match outcome {
Ok(0) => {}
Ok(chunks) => {
let _ = evt_tx.send(AppEvent::FileProgress(FileProgress::Indexed {
name: index.name,
chunks,
}));
}
Err(reason) => skip(reason),
}
});
}
fn extract_file(
path: &std::path::Path,
loc: &'static crate::shared::i18n::Locale,
hint: Option<&str>,
) -> Result<ExtractedFile, String> {
let meta = std::fs::metadata(path)
.map_err(|e| loc.tf("ui.err.file_unavailable", &[("err", &e.to_string())]))?;
if !meta.is_file() {
return Err(loc.t("ui.err.file_not_a_file").to_string());
}
if meta.len() > MAX_ATTACH_BYTES {
return Err(loc.tf(
"ui.err.file_too_big",
&[
(
"size",
&crate::entities::attachment::format_bytes(meta.len() as usize),
),
(
"max",
&crate::entities::attachment::format_bytes(MAX_ATTACH_BYTES as usize),
),
],
));
}
let bytes = std::fs::read(path)
.map_err(|e| loc.tf("ui.err.file_unavailable", &[("err", &e.to_string())]))?;
let (text, encoding, original) = if crate::features::rag_ingest::is_pdf(path) {
let text = crate::features::doc_extract::extract_pdf(&bytes)
.map_err(|e| loc.tf("ui.err.file_unreadable", &[("err", &e.to_string())]))?;
(text, None, Some(bytes))
} else if crate::features::rag_ingest::is_docx(path) {
let text = crate::features::doc_extract::extract_docx(&bytes)
.map_err(|e| loc.tf("ui.err.file_unreadable", &[("err", &e.to_string())]))?;
(text, None, Some(bytes))
} else {
let markup = crate::shared::text_decode::is_markup_path(path);
match crate::shared::text_decode::decode_file(&bytes, markup, hint) {
Some(file) => {
let encoding = (file.encoding != encoding_rs::UTF_8).then_some(file.encoding);
if crate::features::rag_ingest::is_html(path) {
let text =
crate::features::tools::web::extract_readable(&file.text, usize::MAX);
(text, encoding, Some(bytes))
} else {
(file.text, encoding, None)
}
}
None => (String::new(), None, Some(bytes)),
}
};
if text.trim().is_empty() && original.is_none() {
return Err(loc.t("ui.err.file_empty").to_string());
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
Ok(ExtractedFile {
name,
source: crate::features::rag_ingest::canonical_source(path),
text,
bytes: meta.len() as usize,
encoding,
original,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn ru() -> &'static crate::shared::i18n::Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
#[test]
fn extracts_utf8_text_and_reports_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.md");
std::fs::write(&path, "# Заголовок\n\nтекст заметки").unwrap();
let file = extract_file(&path, ru(), None).unwrap();
assert_eq!(file.name, "notes.md");
assert!(file.text.contains("текст заметки"));
assert_eq!(file.bytes, std::fs::metadata(&path).unwrap().len() as usize);
assert!(file.source.ends_with("notes.md"));
}
#[test]
fn a_legacy_encoded_file_is_attached_in_its_own_encoding() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("report.txt");
let prose = "Выручка за март составила сто двадцать тысяч, за апрель немного больше.";
std::fs::write(&path, encoding_rs::WINDOWS_1251.encode(prose).0).unwrap();
let file = extract_file(&path, ru(), Some("ru")).unwrap();
assert_eq!(file.text, prose);
assert_eq!(
file.encoding.map(encoding_rs::Encoding::name),
Some("windows-1251")
);
}
#[test]
fn source_files_are_attachable_not_just_the_rag_allowlist() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.rs");
std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
let file = extract_file(&path, ru(), None).unwrap();
assert!(file.text.contains("fn main()"));
}
#[test]
fn a_binary_is_kept_while_a_missing_or_empty_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("blob.bin");
std::fs::write(&bin, [0xff, 0xfe, 0x00, 0x01, 0x80]).unwrap();
let kept = extract_file(&bin, ru(), None).expect("a binary is kept, not refused");
assert!(kept.text.trim().is_empty(), "text: {:?}", kept.text);
assert_eq!(
kept.original.as_deref(),
Some(&[0xff, 0xfe, 0x00, 0x01, 0x80][..])
);
assert!(extract_file(&dir.path().join("nope.txt"), ru(), None).is_err());
assert!(extract_file(dir.path(), ru(), None).is_err());
let empty = dir.path().join("empty.txt");
std::fs::write(&empty, " \n\t ").unwrap();
assert!(extract_file(&empty, ru(), None).is_err());
}
#[test]
fn an_extracted_document_keeps_its_original_and_plain_text_does_not() {
let dir = tempfile::tempdir().unwrap();
let page = dir.path().join("page.html");
std::fs::write(
&page,
"<html><head><title>A page</title></head><body><article>\
<p>The readable text of this page runs for several sentences, because that \
is what the extractor weighs a block of prose against the markup around \
it.</p>\
<p>A second paragraph gives it something to keep: the file is read in its \
own encoding, the prose becomes the attachment, and the page itself stays \
with the chat for the code to open.</p>\
</article></body></html>",
)
.unwrap();
let extracted = extract_file(&page, ru(), None).unwrap();
assert!(extracted.text.contains("readable text"), "{extracted:?}");
assert!(
extracted.original.is_some_and(|b| b.starts_with(b"<html>")),
"an extracted page keeps the file it was read from"
);
let notes = dir.path().join("notes.md");
std::fs::write(¬es, "# heading\n\ntext").unwrap();
assert!(
extract_file(¬es, ru(), None).unwrap().original.is_none(),
"plain text needs no second copy — the snapshot is the file"
);
}
#[test]
fn extraction_errors_are_localized_for_all_langs() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.txt");
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let err = extract_file(&missing, loc, None).unwrap_err();
assert!(!err.contains('{') && !err.contains('}'), "{lang:?}: {err}");
if lang == crate::shared::i18n::Lang::En {
assert!(
!err.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {err}"
);
}
}
}
}