use crate::Workspace;
use crate::document::DocOutcome;
use crate::reader_output::Unshown;
use crate::tools::path::PathAccess;
use crate::tools::{ImagePayload, ImagePayloadSource, ToolOutput};
use anyhow::Context;
use std::path::{Path, PathBuf};
const MAX_INJECTED_IMAGES: usize = 5;
const HEAD_SNIFF_BYTES: usize = 16;
const NO_ARTIFACT_DIR_REASON: &str = "could not be converted";
async fn document_target(path: &Path) -> anyhow::Result<Option<String>> {
let Ok(meta) = tokio::fs::metadata(path).await else {
return Ok(None);
};
if !meta.is_file() {
return Ok(None);
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return Ok(None);
};
let Some(head) = peek_head(path).await else {
return Ok(None);
};
if !crate::document::needs_extraction(&head, name) {
return Ok(None);
}
crate::tools::check_size_within(
&meta,
crate::util::FILE_MAX_BYTES,
"File too large to convert",
)?;
Ok(Some(name.to_owned()))
}
struct ConvertedDocument {
text: String,
images: Vec<PathBuf>,
notes: Vec<String>,
unshown: Unshown,
all_page_text_lost: bool,
page_text: bool,
}
enum Conversion {
Reading(ConvertedDocument),
Ordinary,
Unreadable(String),
}
async fn convert_reading(path: &Path, name: &str, dir: &Path) -> Conversion {
match crate::document::convert_document_file(path, name, dir).await {
DocOutcome::Text {
text,
images,
notes,
unshown,
all_page_text_lost,
page_text,
} => Conversion::Reading(ConvertedDocument {
text: crate::util::scrub_credentials(text.trim()),
images,
notes,
unshown,
all_page_text_lost,
page_text,
}),
DocOutcome::Unreadable { reason } => Conversion::Unreadable(reason),
DocOutcome::Unsupported => Conversion::Ordinary,
}
}
pub(super) async fn read_document(
ws: &Workspace,
res: &super::read::ResolvedRead,
access: PathAccess,
) -> anyhow::Result<Option<ToolOutput>> {
let Some(name) = document_target(&res.path).await? else {
return Ok(None);
};
let dir = match create_artifact_dir(ws, access).await {
Ok(dir) => dir,
Err(e) => {
tracing::warn!(error = %e, "Failed to create a document artifact directory");
return Ok(Some(plain_answer(res, NO_ARTIFACT_DIR_REASON)));
}
};
crate::tools::shell::record_spill_owner(dir.clone());
let outcome = match convert_reading(&res.path, &name, &dir).await {
Conversion::Reading(reading) => Some(compose_output(res, &dir, reading).await),
Conversion::Unreadable(reason) => Some(plain_answer(res, &reason)),
Conversion::Ordinary => None,
};
let _ = tokio::fs::remove_dir(&dir).await;
Ok(outcome)
}
pub(crate) async fn read_for_command_line(
ws: &Workspace,
args: &serde_json::Value,
destination: &Path,
) -> anyhow::Result<super::ToolAnswer> {
let path = super::get_str(args, "path")?;
let res = super::read::resolve_content_read(ws, path, PathAccess::Unrestricted).await?;
let display = res.path.display().to_string();
let fallback = crate::util::name_stem(crate::util::file_name_or_path(&display)).to_string();
let base =
crate::tools::document::sanitize_base(super::get_opt_str(args, "file_name"), &fallback);
let base = if base.is_empty() {
"document".to_string()
} else {
base
};
let target = document_target(&res.path).await?;
let folder = crate::util::create_unique_dir(destination, &base)
.await
.with_context(|| format!("failed to create a folder in {}", destination.display()))?;
let answer = write_command_line_reading(&res, &folder, target).await;
if answer.is_err() {
let _ = tokio::fs::remove_dir_all(&folder).await;
}
answer
}
async fn write_command_line_reading(
res: &super::read::ResolvedRead,
folder: &Path,
target: Option<String>,
) -> anyhow::Result<super::ToolAnswer> {
let display = res.path.display().to_string();
let (reading, is_document) = match target {
Some(name) => match convert_reading(&res.path, &name, folder).await {
Conversion::Reading(reading) => (reading, true),
Conversion::Unreadable(reason) => {
return Err(anyhow::anyhow!(answer_line(&display, reason)));
}
Conversion::Ordinary => (ordinary_reading(res).await?, false),
},
None => (ordinary_reading(res).await?, false),
};
let mut produced: Vec<PathBuf> = Vec::new();
if !reading.text.is_empty() {
let folder_name = folder
.file_name()
.expect("a created folder has a name")
.to_string_lossy();
let text_path = folder.join(format!("{folder_name}.txt"));
tokio::fs::write(&text_path, reading.text.as_bytes())
.await
.with_context(|| format!("failed to write {}", text_path.display()))?;
produced.push(text_path);
}
produced.extend(reading.images.iter().cloned());
let images_delivered = !reading.images.is_empty();
let mut report = Vec::new();
if is_document && reading.text.is_empty() && !reading.all_page_text_lost {
report.push(no_text_line(&display, images_delivered));
}
report.extend(reading_notes(&display, &reading, images_delivered));
let answer = if produced.is_empty() {
let _ = tokio::fs::remove_dir_all(folder).await;
report.join("\n")
} else {
let names: Vec<String> = produced
.iter()
.map(|path| crate::util::file_name_or_path(&path.display().to_string()).to_string())
.collect();
let mut sizes = Vec::with_capacity(produced.len());
for path in &produced {
let meta = tokio::fs::metadata(path)
.await
.with_context(|| format!("failed to read back {}", path.display()))?;
sizes.push(meta.len());
}
let mut lines = vec![crate::tools::document::describe("read", &names, &sizes)];
lines.extend(report.iter().cloned());
lines.extend(
produced
.iter()
.map(|path| crate::tools::document::file_marker(path)),
);
let full = lines.join("\n");
if full.len() <= crate::util::TOOL_OUTPUT_BUDGET_BYTES {
full
} else {
let lead = format!(
"read {} files — they are all in {}",
produced.len(),
folder.display()
);
std::iter::once(lead)
.chain(report)
.collect::<Vec<_>>()
.join("\n")
}
};
Ok(if reading.all_page_text_lost {
super::ToolAnswer::partial(answer)
} else {
super::ToolAnswer::done(answer)
})
}
async fn ordinary_reading(res: &super::read::ResolvedRead) -> anyhow::Result<ConvertedDocument> {
let text = super::read::read_resolved(res, &serde_json::json!({})).await?;
Ok(ConvertedDocument {
page_text: !text.is_empty(),
text,
images: Vec::new(),
notes: Vec::new(),
unshown: Unshown::default(),
all_page_text_lost: false,
})
}
fn no_text_line(display: &str, images_delivered: bool) -> String {
answer_line(
display,
if images_delivered {
crate::document::NO_TEXT_LAYER_NOTE
} else {
crate::document::NO_TEXT_NOTE
},
)
}
fn reading_notes(
display: &str,
reading: &ConvertedDocument,
images_delivered: bool,
) -> Vec<String> {
let mut notes: Vec<String> = reading
.unshown
.lines()
.iter()
.chain(&reading.notes)
.map(|body| answer_line(display, body))
.collect();
if !reading.page_text && !reading.all_page_text_lost && !reading.text.is_empty() {
notes.push(no_text_line(display, images_delivered));
}
notes
}
fn plain_answer(res: &super::read::ResolvedRead, reason: &str) -> ToolOutput {
ToolOutput {
text: crate::tools::with_recovery_note(
res.recovery_note.as_deref(),
answer_line(&res.path.display().to_string(), reason),
),
image_payloads: Vec::new(),
text_is_content: true,
}
}
fn answer_line(display: &str, body: impl std::fmt::Display) -> String {
format!("[{display}: {body}]")
}
async fn create_artifact_dir(ws: &Workspace, access: PathAccess) -> anyhow::Result<PathBuf> {
let parent = if access == PathAccess::Workspace {
ws.as_path().join("uploads")
} else {
crate::tools::shell::agent_temp_dir()
.ok_or_else(|| anyhow::anyhow!("no temp directory available for document conversion"))?
};
tokio::fs::create_dir_all(&parent)
.await
.with_context(|| format!("failed to create {}", parent.display()))?;
let dir = parent.join(format!("read_{:016x}", rand::random::<u64>()));
tokio::fs::create_dir(&dir)
.await
.with_context(|| format!("failed to create {}", dir.display()))?;
Ok(dir)
}
async fn peek_head(path: &Path) -> Option<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await.ok()?;
let mut head = [0u8; HEAD_SNIFF_BYTES];
let mut filled = 0;
while filled < HEAD_SNIFF_BYTES {
match file.read(&mut head[filled..]).await {
Ok(0) => break,
Ok(n) => filled += n,
Err(_) => return None,
}
}
Some(head[..filled].to_vec())
}
async fn attach_images(images: &[PathBuf]) -> (Vec<ImagePayload>, usize) {
let mut attached = Vec::new();
let mut unattached = 0usize;
if images.len() > MAX_INJECTED_IMAGES {
return (attached, unattached);
}
for image in images {
match crate::util::local_image_to_compressed_data_uri_with_meta(image).await {
Ok(meta) => attached.push(ImagePayload::from_compressed_meta(
image,
meta,
None,
ImagePayloadSource::Generated,
)),
Err(e) => {
tracing::warn!(
path = %image.display(),
error = %e,
"Failed to encode an extracted document image"
);
unattached += 1;
}
}
}
(attached, unattached)
}
async fn compose_output(
res: &super::read::ResolvedRead,
dir: &Path,
reading: ConvertedDocument,
) -> ToolOutput {
let display = res.path.display().to_string();
let over_cap = reading.images.len() > MAX_INJECTED_IMAGES;
let (attached, unattached) = attach_images(&reading.images).await;
let mut text_block = if reading.text.is_empty() {
if reading.all_page_text_lost {
String::new()
} else {
no_text_line(&display, !attached.is_empty())
}
} else {
format!(
"{}\n\n{}",
answer_line(&display, "extracted text follows"),
reading.text
)
};
let mut trailing = reading_notes(&display, &reading, !attached.is_empty());
if unattached > 0 {
trailing.push(answer_line(
&display,
format!(
"{unattached} extracted image(s) could not be attached; they are in {} — read them individually if you need them",
dir.display()
),
));
}
let (full_listing, folder_listing) = if over_cap {
let head = format!(
"[{display}: {} extracted image(s) were not attached ({MAX_INJECTED_IMAGES} is the per-call limit);",
reading.images.len()
);
let mut listing = vec![format!("{head} read them individually as images:]")];
listing.extend(
reading
.images
.iter()
.map(|image| image.display().to_string()),
);
let folder = vec![format!(
"{head} they are all in {} — list that folder, then read the images you need.]",
dir.display()
)];
(listing, folder)
} else {
(Vec::new(), Vec::new())
};
let compose = |text_block: &str, listing: &[String]| {
let mut lines: Vec<&str> = Vec::with_capacity(2 + trailing.len() + listing.len());
if !text_block.is_empty() {
lines.push(text_block);
}
lines.extend(trailing.iter().map(String::as_str));
lines.extend(listing.iter().map(String::as_str));
crate::tools::with_recovery_note(res.recovery_note.as_deref(), lines.join("\n"))
};
let fits = |text_block: &str, listing: &[String]| {
compose(text_block, listing).len() <= crate::util::TOOL_OUTPUT_BUDGET_BYTES
};
let pick_listing = |text_block: &str| {
if fits(text_block, &full_listing) {
&full_listing
} else {
&folder_listing
}
};
let mut listing = pick_listing(&text_block);
if !fits(&text_block, listing) && !reading.text.is_empty() {
text_block = spill_text(dir, &display, &reading.text).await;
listing = pick_listing(&text_block);
}
ToolOutput {
text: compose(&text_block, listing),
image_payloads: attached,
text_is_content: true,
}
}
async fn spill_text(dir: &Path, display: &str, text: &str) -> String {
let path = dir.join(crate::tools::path::format_spill_filename());
match tokio::fs::write(&path, text.as_bytes()).await {
Ok(()) => answer_line(
display,
crate::document::spilled_text_note(text.chars().count(), &path),
),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to save the extracted document text"
);
answer_line(display, "the extracted text could not be saved")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::test_fixtures::{
PdfFixture, multi_page_pdf, one_page_pdf, pdf_text_string,
};
use crate::legacy::test_fixtures::{
cfb, doc_fixture, doc_picture_fixture, ppt_picture_fixture, xls_picture_fixture,
};
use crate::ooxml::test_fixtures::{DOCX_BODY, zip_fixture};
use crate::util::test::noisy_png;
use tempfile::TempDir;
fn temp_workspace(files: &[(&str, &[u8])]) -> (TempDir, Workspace) {
let dir = TempDir::new().expect("tempdir");
for (name, bytes) in files {
std::fs::write(dir.path().join(name), bytes).expect("write fixture");
}
let ws = crate::workspace::test_ws(dir.path());
(dir, ws)
}
struct SpillOwner(String);
impl SpillOwner {
fn new() -> Self {
Self(format!("read-document-test-{:016x}", rand::random::<u64>()))
}
async fn scope<T>(&self, future: impl std::future::Future<Output = T>) -> T {
crate::agent::CURRENT_TOOL_AGENT_ID
.scope(Some(self.0.clone()), future)
.await
}
}
impl Drop for SpillOwner {
fn drop(&mut self) {
crate::tools::shell::cleanup_agent_spills(&self.0);
}
}
async fn convert(
owner: &SpillOwner,
ws: &Workspace,
name: &str,
access: PathAccess,
) -> Option<ToolOutput> {
let res = super::super::read::resolve_content_read(ws, name, access)
.await
.expect("resolve the fixture");
owner
.scope(read_document(ws, &res, access))
.await
.expect("read the document")
}
async fn command_line_read(
ws: &Workspace,
destination: &Path,
path: &str,
file_name: Option<&str>,
) -> anyhow::Result<crate::tools::ToolAnswer> {
let mut args = serde_json::json!({
"action": crate::cli::DOCUMENT_READ,
"path": path,
});
if let Some(file_name) = file_name {
args["file_name"] = serde_json::Value::String(file_name.to_string());
}
read_for_command_line(ws, &args, destination).await
}
fn marked_files(answer: &str) -> Vec<PathBuf> {
answer
.split(crate::tools::document::FILE_MARKER)
.skip(1)
.map(|rest| PathBuf::from(rest.split(']').next().unwrap_or_default()))
.collect()
}
fn folder_names(folder: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(folder)
.expect("the reading's folder")
.map(|entry| {
entry
.expect("a folder entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
fn files_with_extension(folder: &Path, extension: &str) -> Vec<String> {
folder_names(folder)
.into_iter()
.filter(|name| {
Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case(extension))
})
.collect()
}
fn raster_only_pdf() -> Vec<u8> {
multi_page_pdf(&[(
"/MediaBox [0 0 612 792]",
b"0.1 0.5 0.9 rg 0 0 612 792 re f",
)])
}
fn pdf_with_no_media_box() -> Vec<u8> {
multi_page_pdf(&[("", b"BT /F1 24 Tf 72 700 Td (Page text long enough) Tj ET")])
}
fn pdf_scan_with_only_a_form_value() -> Vec<u8> {
const CONTENT: &[u8] = b"0.1 0.5 0.9 rg 0 0 612 792 re f";
let mut fixture = PdfFixture::new();
let field = fixture.push(format!(
"<< /Subtype /Widget /FT /Tx /T (Name) /V {} /Rect [0 0 0 0] >>",
pdf_text_string("Иван Петров")
));
let acroform = fixture.push(format!("<< /Fields [{field}] >>"));
one_page_pdf(
fixture,
CONTENT,
&format!("/MediaBox [0 0 612 792] /Annots [{field}]"),
&format!(" /AcroForm {acroform}"),
)
}
fn spilled_path(text: &str) -> PathBuf {
let start = text.find("saved to ").expect("a spill line") + "saved to ".len();
let end = start + text[start..].find(" — read that file").expect("a pointer");
PathBuf::from(&text[start..end])
}
const EMPTY_DOCX_BODY: &[u8] = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body></w:body></w:document>"#;
#[tokio::test]
async fn text_free_documents_claim_images_only_when_attached() {
let empty = zip_fixture(&[("word/document.xml", EMPTY_DOCX_BODY)]);
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("empty.docx", &empty)]);
let out = convert(&owner, &ws, "empty.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert_eq!(out.image_payloads.len(), 0);
assert!(
out.text.contains("empty.docx: no text could be extracted]"),
"{:?}",
out.text
);
let with_media = docx_with_images(EMPTY_DOCX_BODY, 6);
let (_dir, ws) = temp_workspace(&[("scanned.docx", &with_media)]);
let out = convert(&owner, &ws, "scanned.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert_eq!(
out.image_payloads.len(),
0,
"over the cap nothing is attached"
);
assert!(
out.text
.contains("scanned.docx: no text could be extracted]\n"),
"{:?}",
out.text
);
assert!(
!out.text.contains(crate::document::NO_TEXT_LAYER_NOTE),
"images that were not attached are not called attached: {:?}",
out.text
);
}
#[tokio::test]
async fn a_cropped_pdf_page_delivers_only_what_it_shows() {
let bytes = multi_page_pdf(&[(
"/MediaBox [0 0 612 792] /CropBox [0 0 320 792]",
b"BT /F1 24 Tf 72 700 Td (Visible line of text) Tj 300 0 Td (Hidden marker) Tj ET",
)]);
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("cropped.pdf", &bytes)]);
let out = convert(&owner, &ws, "cropped.pdf", PathAccess::Allowlisted)
.await
.expect("a .pdf is a document");
assert!(
out.text.contains("Visible line of text"),
"the text inside the crop box is delivered: {:?}",
out.text
);
assert!(
!out.text.contains("Hidden"),
"the text the crop box hides is not delivered: {:?}",
out.text
);
}
#[tokio::test]
async fn image_only_pdf_pages_come_back_as_images() {
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("scan.pdf", &raster_only_pdf())]);
let out = convert(&owner, &ws, "scan.pdf", PathAccess::Allowlisted)
.await
.expect("a .pdf is a document");
assert_eq!(
out.image_payloads.len(),
1,
"one rasterized page: {}",
out.text
);
assert!(
out.text
.contains("no text could be extracted — the content was provided as images"),
"{}",
out.text
);
assert!(
out.text_is_content,
"the page images supplement, never replace, the answer"
);
}
#[tokio::test]
async fn the_answer_names_what_the_reading_did_not_show() {
let owner = SpillOwner::new();
let with_media = zip_fixture(&[
("word/document.xml", DOCX_BODY),
("word/media/clip.mp4", b"not a picture at all"),
(
"word/media/scan.tiff",
b"a picture this pipeline does not convert",
),
("word/embeddings/oleObject1.bin", b"an object nothing reads"),
]);
let (_dir, ws) = temp_workspace(&[("clip.docx", &with_media)]);
let out = convert(&owner, &ws, "clip.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert!(
out.text.contains("1 embedded video(s) not shown]"),
"{:?}",
out.text
);
assert!(
out.text.contains("1 embedded object part(s) not shown]"),
"{:?}",
out.text
);
assert!(
out.text
.contains("skipped 1 embedded image(s) in a format this pipeline cannot convert]"),
"the picture the pipeline cannot convert keeps its reason: {:?}",
out.text
);
let plain = zip_fixture(&[("word/document.xml", DOCX_BODY)]);
let (_dir, ws) = temp_workspace(&[("plain.docx", &plain)]);
let out = convert(&owner, &ws, "plain.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert!(
!out.text.contains("not shown]"),
"a reading with nothing left out reports nothing: {:?}",
out.text
);
}
#[tokio::test]
async fn unread_text_is_named_not_reported_as_absent() {
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("broken.pdf", &pdf_with_no_media_box())]);
let out = convert(&owner, &ws, "broken.pdf", PathAccess::Allowlisted)
.await
.expect("a .pdf is a document");
assert!(
out.text
.contains("broken.pdf: the text of page 1 could not be read (1 of 1 pages)"),
"{}",
out.text
);
assert!(
!out.text.contains(crate::document::NO_TEXT_NOTE),
"a reader failure is not a document without text: {}",
out.text
);
assert_eq!(
out.image_payloads.len(),
1,
"the page that could not be read comes back as an image: {}",
out.text
);
}
#[tokio::test]
async fn a_scan_that_carries_only_form_values_is_not_an_empty_document() {
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("filled.pdf", &pdf_scan_with_only_a_form_value())]);
let out = convert(&owner, &ws, "filled.pdf", PathAccess::Allowlisted)
.await
.expect("a .pdf is a document");
assert_eq!(
out.image_payloads.len(),
1,
"the text-free page is rasterized: {}",
out.text
);
assert!(
out.text
.contains("Form fields:\nName (text) on page 1: Иван Петров"),
"the filled value is the document's text: {}",
out.text
);
assert!(
out.text.contains(crate::document::NO_TEXT_LAYER_NOTE),
"the only text is the field list, so the page came back as an image: {}",
out.text
);
}
#[tokio::test]
async fn corrupt_pdf_is_reported_not_failed() {
crate::util::test::init_test_stores().await;
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("broken.pdf", b"%PDF-1.4\ngarbage")]);
let out = convert(&owner, &ws, "brokn.pdf", PathAccess::Allowlisted)
.await
.expect("a .pdf is a document");
assert!(out.text.starts_with("[Recovered path: "), "{}", out.text);
assert!(out.text.contains("could not be parsed"), "{}", out.text);
assert!(out.image_payloads.is_empty());
}
#[tokio::test]
async fn a_cfb_container_is_answered_by_its_contents_then_its_name() {
crate::util::test::init_test_stores().await;
let owner = SpillOwner::new();
let doc = doc_fixture("Hello legacy doc", &[], false);
let other = cfb(&[("__properties_version1.0", b"outlook".as_slice())]);
let (_dir, ws) = temp_workspace(&[
("words.docx", &doc),
("old.doc", &other),
("mail.msg", &other),
]);
let out = convert(&owner, &ws, "words.docx", PathAccess::Allowlisted)
.await
.expect("a legacy document is a document");
assert!(
out.text.contains("Hello legacy doc"),
"the old document's own reading is delivered: {}",
out.text
);
let out = convert(&owner, &ws, "old.doc", PathAccess::Allowlisted)
.await
.expect("a container named as an old document is answered, not failed");
assert!(
out.text.contains("corrupt or unreadable .doc"),
"the legacy reader's own verdict answers it: {}",
out.text
);
assert!(
convert(&owner, &ws, "mail.msg", PathAccess::Allowlisted)
.await
.is_none(),
"a container naming no document is left to the ordinary read"
);
}
fn docx_with_images(body: &[u8], count: usize) -> Vec<u8> {
let png = noisy_png(16, 16);
let names: Vec<String> = (1..=count)
.map(|page| format!("word/media/page_{page}.png"))
.collect();
let mut entries: Vec<(&str, &[u8])> = vec![("word/document.xml", body)];
entries.extend(names.iter().map(|name| (name.as_str(), png.as_slice())));
zip_fixture(&entries)
}
#[tokio::test]
async fn images_over_the_cap_are_delivered_as_paths() {
let fixture = docx_with_images(DOCX_BODY, 6);
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("report.docx", &fixture)]);
let out = convert(&owner, &ws, "report.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert!(
out.image_payloads.is_empty(),
"nothing may be attached: {}",
out.text
);
assert!(
out.text
.contains("6 extracted image(s) were not attached (5 is the per-call limit); read them individually as images:]"),
"the annotation is one closed envelope: {}",
out.text
);
let listed: Vec<&str> = out
.text
.lines()
.skip_while(|line| !line.contains("were not attached"))
.skip(1)
.collect();
assert_eq!(listed.len(), 6, "one line per produced image: {listed:?}");
assert!(
listed
.iter()
.all(|path| std::path::Path::new(path).is_absolute()),
"each image is named by its absolute path: {listed:?}"
);
}
#[tokio::test]
async fn legacy_readings_deliver_their_pictures_like_a_modern_reading() {
let png = noisy_png(16, 16);
let fixtures = [
("old.doc", doc_picture_fixture("Body text", &png)),
("old.xls", xls_picture_fixture(&png)),
("old.ppt", ppt_picture_fixture("Slide title", &png)),
];
let owner = SpillOwner::new();
for (name, fixture) in &fixtures {
let (dir, ws) = temp_workspace(&[(name, fixture)]);
let out = convert(&owner, &ws, name, PathAccess::Allowlisted)
.await
.expect("an old binary document is a document");
assert_eq!(
out.image_payloads.len(),
1,
"{name}: the embedded picture is attached: {}",
out.text
);
assert_eq!(out.image_payloads[0].format, "PNG", "{name}");
assert!(
!out.text.contains("not shown]"),
"{name}: a delivered picture is not reported as left out: {}",
out.text
);
assert_eq!(
std::fs::read(dir.path().join(name)).expect("re-read the source"),
*fixture,
"{name}: the reading never modifies the source file"
);
}
}
#[tokio::test]
async fn a_listing_too_long_to_fit_names_the_folder() {
let fixture = docx_with_images(DOCX_BODY, 120);
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("many.docx", &fixture)]);
let out = convert(&owner, &ws, "many.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert!(
out.text.len() <= crate::util::TOOL_OUTPUT_BUDGET_BYTES,
"the answer must fit the budget it is formatted against: {} bytes",
out.text.len()
);
assert!(
out.text.contains("they are all in "),
"the folder is named rather than a truncated listing: {}",
out.text
);
}
#[tokio::test]
async fn plain_text_is_not_a_document() {
use crate::Tool;
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("notes.md", b"# Notes\n")]);
assert!(
convert(&owner, &ws, "notes.md", PathAccess::Allowlisted)
.await
.is_none()
);
let out = owner
.scope(
crate::tools::read::ReadTool::general()
.execute_with_payloads(&ws, serde_json::json!({"path": "notes.md"})),
)
.await
.expect("read the text file");
assert!(out.text.contains("1: # Notes"), "{}", out.text);
assert!(out.image_payloads.is_empty());
}
#[tokio::test]
async fn read_tool_delivers_the_converted_document() {
use crate::Tool;
let owner = SpillOwner::new();
let fixture = zip_fixture(&[("word/document.xml", DOCX_BODY)]);
let (_dir, ws) = temp_workspace(&[("report.docx", &fixture)]);
let out = owner
.scope(
crate::tools::read::ReadTool::general()
.execute_with_payloads(&ws, serde_json::json!({"path": "report.docx"})),
)
.await
.expect("read the document");
assert!(out.text.contains("extracted text follows"), "{}", out.text);
assert!(
out.text.contains("First paragraph\nSecond paragraph"),
"{}",
out.text
);
assert!(
!out.text.lines().any(|line| line.starts_with("1: ")),
"a document is converted, not line-numbered: {}",
out.text
);
assert!(
!out.text.contains("too long to inline"),
"short text is inlined, never spilled: {}",
out.text
);
assert!(
out.image_payloads.is_empty(),
"the fixture has no media entries"
);
}
#[tokio::test]
async fn strict_read_keeps_artifacts_inside_the_workspace() {
let (dir, ws) = temp_workspace(&[("scan.pdf", &raster_only_pdf())]);
let out = convert(&SpillOwner::new(), &ws, "scan.pdf", PathAccess::Workspace)
.await
.expect("a .pdf is a document");
let image = out.image_payloads.first().expect("the page is attached");
let uploads = std::fs::canonicalize(dir.path())
.expect("canonical workspace")
.join("uploads");
assert!(
crate::util::is_within(Path::new(&image.path), &uploads),
"a restricted read must place artifacts where it can open them: {}",
image.path
);
}
#[tokio::test]
async fn long_text_with_many_images_is_spilled_not_truncated() {
let filler = "lorem ipsum dolor sit amet ".repeat(1_200);
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>FIRST-MARKER</w:t></w:r></w:p><w:p><w:r><w:t>{filler}</w:t></w:r></w:p><w:p><w:r><w:t>LAST-MARKER</w:t></w:r></w:p></w:body></w:document>"#
);
let fixture = docx_with_images(body.as_bytes(), 6);
let owner = SpillOwner::new();
let (_dir, ws) = temp_workspace(&[("long.docx", &fixture)]);
let out = convert(&owner, &ws, "long.docx", PathAccess::Allowlisted)
.await
.expect("a .docx is a document");
assert!(
out.text.len() <= crate::util::TOOL_OUTPUT_BUDGET_BYTES,
"the answer must fit the budget it is formatted against: {} bytes",
out.text.len()
);
assert!(
!out.text.contains("FIRST-MARKER"),
"long extracted text is spilled, not inlined: {}",
out.text
);
let spilled =
std::fs::read_to_string(spilled_path(&out.text)).expect("read the spill file");
assert!(
spilled.contains("FIRST-MARKER") && spilled.contains("LAST-MARKER"),
"the spill file holds the whole text, head to tail"
);
assert_eq!(
out.text
.lines()
.filter(|line| std::path::Path::new(line).extension() == Some("png".as_ref()))
.count(),
6,
"spilling the text frees room for the per-image paths again: {}",
out.text
);
}
#[tokio::test]
async fn command_line_reading_writes_the_readers_text_and_every_image() {
crate::util::test::init_test_stores().await;
let fixture = docx_with_images(DOCX_BODY, 3);
let (_dir, ws) = temp_workspace(&[("report.docx", &fixture)]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "report.docx", None)
.await
.expect("a document is read");
assert!(
!answer.partial,
"a whole reading is not partial: {}",
answer.text
);
let folder = destination.path().join("report");
assert!(folder.is_dir(), "the reading writes into its own folder");
let text = std::fs::read_to_string(folder.join("report.txt"))
.expect("the reader's text is written");
assert!(
text.contains("First paragraph") && text.contains("Second paragraph"),
"{text}"
);
let images = files_with_extension(&folder, "png");
assert_eq!(images.len(), 3, "every media entry is written");
let marked = marked_files(&answer.text);
assert_eq!(
marked.len(),
4,
"the text and three images are named: {}",
answer.text
);
assert!(
marked
.iter()
.all(|path| path.is_absolute() && path.exists() && path.starts_with(&folder)),
"{marked:?}"
);
}
#[tokio::test]
async fn command_line_reading_converts_an_old_binary_document() {
crate::util::test::init_test_stores().await;
let png = noisy_png(16, 16);
let fixture = doc_picture_fixture("Legacy body text", &png);
let (_dir, ws) = temp_workspace(&[("old.doc", &fixture)]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "old.doc", None)
.await
.expect("an old document is read");
assert!(!answer.partial);
let folder = destination.path().join("old");
let text = std::fs::read_to_string(folder.join("old.txt")).expect("the text is written");
assert!(text.contains("Legacy body text"), "{text}");
assert_eq!(
files_with_extension(&folder, "png").len(),
1,
"the embedded picture is written beside the text"
);
assert_eq!(
marked_files(&answer.text).len(),
2,
"the text and the picture are named: {}",
answer.text
);
}
#[tokio::test]
async fn command_line_reading_of_a_text_free_pdf_writes_an_image_per_page_and_no_text_file() {
crate::util::test::init_test_stores().await;
let painted = b"0.1 0.5 0.9 rg 0 0 612 792 re f".as_slice();
let bytes = multi_page_pdf(&[
("/MediaBox [0 0 612 792]", painted),
("/MediaBox [0 0 612 792]", painted),
]);
let (_dir, ws) = temp_workspace(&[("scan.pdf", &bytes)]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "scan.pdf", None)
.await
.expect("a PDF is read");
assert!(!answer.partial);
assert!(
answer.text.contains(crate::document::NO_TEXT_LAYER_NOTE),
"{}",
answer.text
);
let names = folder_names(&destination.path().join("scan"));
assert_eq!(
names,
["page_1.jpg", "page_2.jpg"],
"one raster per page: {names:?}"
);
assert!(
files_with_extension(&destination.path().join("scan"), "txt").is_empty(),
"a text-free document writes no text file: {names:?}"
);
}
#[tokio::test]
async fn command_line_reading_that_lost_its_text_is_partial() {
crate::util::test::init_test_stores().await;
let (_dir, ws) = temp_workspace(&[("broken.pdf", &pdf_with_no_media_box())]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "broken.pdf", None)
.await
.expect("the page that still renders is delivered");
assert!(answer.partial, "{}", answer.text);
assert!(
answer
.text
.contains("broken.pdf: the text of page 1 could not be read (1 of 1 pages)"),
"the reader's own wording: {}",
answer.text
);
assert!(
!answer.text.contains(crate::document::NO_TEXT_NOTE),
"a reader failure is not a document without text: {}",
answer.text
);
assert_eq!(
files_with_extension(&destination.path().join("broken"), "jpg"),
["page_1.jpg"],
"the page it could render is still written"
);
assert!(
files_with_extension(&destination.path().join("broken"), "txt").is_empty(),
"no text was extracted, so no text file"
);
}
#[tokio::test]
async fn command_line_reading_of_nothing_to_write_keeps_the_readers_words() {
crate::util::test::init_test_stores().await;
let fixture = zip_fixture(&[("word/document.xml", EMPTY_DOCX_BODY)]);
let (_dir, ws) = temp_workspace(&[("blank.docx", &fixture)]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "blank.docx", None)
.await
.expect("a document with no text is still read");
assert!(!answer.partial);
assert!(
answer
.text
.contains("blank.docx: no text could be extracted]"),
"the reader's own sentence: {}",
answer.text
);
assert_eq!(
std::fs::read_dir(destination.path())
.expect("the destination")
.count(),
0,
"nothing produced, so no folder"
);
}
#[tokio::test]
async fn command_line_reading_leaves_no_folder_for_an_unreadable_document() {
crate::util::test::init_test_stores().await;
let (_dir, ws) = temp_workspace(&[("broken.pdf", b"%PDF-1.4\ngarbage")]);
let destination = TempDir::new().expect("destination");
let error = command_line_read(&ws, destination.path(), "broken.pdf", None)
.await
.err()
.expect("a corrupt PDF is a refusal");
let message = format!("{error:#}");
assert!(message.contains("could not be parsed"), "{message}");
assert!(message.contains("broken.pdf"), "{message}");
assert!(
!destination.path().join("broken").exists(),
"a failed reading leaves no folder"
);
}
#[tokio::test]
async fn command_line_reading_reports_a_protected_document_in_the_readers_words() {
crate::util::test::init_test_stores().await;
let fixture = doc_fixture("secret", &[], true);
let (_dir, ws) = temp_workspace(&[("secret.doc", &fixture)]);
let destination = TempDir::new().expect("destination");
let error = command_line_read(&ws, destination.path(), "secret.doc", None)
.await
.err()
.expect("a protected document is a refusal");
let message = format!("{error:#}");
assert!(message.contains("password-protected"), "{message}");
assert_eq!(
std::fs::read_dir(destination.path())
.expect("the destination")
.count(),
0,
"a failed reading writes nothing"
);
}
#[tokio::test]
async fn command_line_reading_of_a_non_document_is_the_ordinary_read() {
crate::util::test::init_test_stores().await;
let (_dir, ws) = temp_workspace(&[("notes.md", b"first\nsecond\n"), ("empty.md", b"")]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "notes.md", None)
.await
.expect("a text file is read");
assert!(!answer.partial);
let text = std::fs::read_to_string(destination.path().join("notes/notes.txt"))
.expect("the ordinary read's rendering");
assert!(
text.contains("1: first") && text.contains("2: second"),
"the fallback is the read tool's line-numbered rendering: {text}"
);
let empty = command_line_read(&ws, destination.path(), "empty.md", None)
.await
.expect("an empty file is read");
assert_eq!(empty.text, "", "the read tool's answer for it is empty");
assert!(
!destination.path().join("empty").exists(),
"nothing produced, so no folder"
);
}
#[tokio::test]
async fn command_line_reading_is_uncapped() {
crate::util::test::init_test_stores().await;
let filler = "lorem ipsum dolor sit amet ".repeat(400);
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{filler}</w:t></w:r></w:p></w:body></w:document>"#
);
let fixture = docx_with_images(body.as_bytes(), 120);
let (_dir, ws) = temp_workspace(&[("many.docx", &fixture)]);
let destination = TempDir::new().expect("destination");
let answer = command_line_read(&ws, destination.path(), "many.docx", None)
.await
.expect("a big document is read");
assert!(!answer.partial);
let folder = destination.path().join("many");
let images = files_with_extension(&folder, "png").len();
assert_eq!(images, 120, "every image is written, with no cap");
let text = std::fs::read(folder.join("many.txt")).expect("the text is written");
assert!(
text.len() > crate::util::TOOL_OUTPUT_BUDGET_BYTES,
"the whole text is written: {} bytes",
text.len()
);
assert!(
!answer.text.contains("saved to "),
"the command line never spills: {}",
answer.text
);
assert!(
!answer.text.contains("too long to inline"),
"{}",
answer.text
);
assert!(
answer.text.contains("they are all in"),
"the listing collapses to the folder: {}",
answer.text
);
assert!(
answer.text.len() <= crate::util::TOOL_OUTPUT_BUDGET_BYTES,
"the collapsed answer fits the shared budget: {} bytes",
answer.text.len()
);
}
#[tokio::test]
async fn command_line_reading_takes_its_folder_name_from_the_flag() {
crate::util::test::init_test_stores().await;
let fixture = zip_fixture(&[("word/document.xml", DOCX_BODY)]);
let (_dir, ws) = temp_workspace(&[("report.docx", &fixture)]);
let destination = TempDir::new().expect("destination");
command_line_read(&ws, destination.path(), "report.docx", Some("dir/summary"))
.await
.expect("a document is read");
assert_eq!(
folder_names(&destination.path().join("summary")),
["summary.txt"]
);
}
#[tokio::test]
async fn command_line_reading_twice_gets_the_familys_numeric_suffix() {
crate::util::test::init_test_stores().await;
let fixture = zip_fixture(&[("word/document.xml", DOCX_BODY)]);
let (_dir, ws) = temp_workspace(&[("2024.report.docx", &fixture)]);
let destination = TempDir::new().expect("destination");
command_line_read(&ws, destination.path(), "2024.report.docx", None)
.await
.expect("first reading");
let first = std::fs::read_to_string(destination.path().join("2024.report/2024.report.txt"))
.expect("the first folder's text");
let second = command_line_read(&ws, destination.path(), "2024.report.docx", None)
.await
.expect("second reading");
assert!(!second.partial);
let second_folder = destination.path().join("2024.report_2");
assert!(
second_folder.is_dir(),
"the family's numeric suffix disambiguates a dotted base: {:?}",
folder_names(destination.path())
);
assert_eq!(
std::fs::read_to_string(second_folder.join("2024.report_2.txt"))
.expect("the second text"),
first,
"the same reading, delivered again"
);
assert_eq!(
std::fs::read_to_string(destination.path().join("2024.report/2024.report.txt"))
.expect("the first folder is still there"),
first,
"the first reading is untouched"
);
}
#[tokio::test]
async fn command_line_reading_refuses_the_products_live_store_before_reading() {
crate::util::test::init_test_stores().await;
let (_dir, ws) = temp_workspace(&[]);
let destination = TempDir::new().expect("destination");
let store = crate::db::store_db_path(
crate::util::test::test_root(),
crate::db::CONSOLIDATED_DB_NAME,
);
let error = command_line_read(&ws, destination.path(), &store.to_string_lossy(), None)
.await
.err()
.expect("the live store is refused");
let message = format!("{error:#}");
assert!(message.contains("live databases"), "{message}");
assert_eq!(
std::fs::read_dir(destination.path())
.expect("the destination")
.count(),
0,
"nothing was created"
);
}
}