paperless-cli 2.1.0

Rust TUI and LLM-friendly client for Paperless-ngx
Documentation
use std::path::PathBuf;

use paperless_cli::pdf::{read_local_pdf_info, read_local_pdf_text, PdfError};
use tempfile::tempdir;

#[test]
fn read_local_pdf_text_returns_page_text() {
    let pdf_path = write_fixture_pdf();

    let text = read_local_pdf_text(&pdf_path).unwrap();
    assert_eq!(text, "Hello from PDF");
}

#[test]
fn read_local_pdf_info_returns_metadata_and_file_details() {
    let pdf_path = write_fixture_pdf();

    let info = read_local_pdf_info(&pdf_path).unwrap();
    assert_eq!(info.file_name, "sample.pdf");
    assert_eq!(info.page_count, 1);
    assert!(info.size_bytes > 0);
    assert_eq!(info.metadata.title.as_deref(), Some("Invoice 2026"));
    assert_eq!(info.metadata.author.as_deref(), Some("Paperless"));
    assert_eq!(info.metadata.subject.as_deref(), Some("Metadata test"));
    assert_eq!(info.metadata.keywords.as_deref(), Some("invoice, pdf"));
    assert_eq!(info.metadata.creator.as_deref(), Some("Codex"));
    assert_eq!(info.metadata.producer.as_deref(), Some("Unit Test"));
    assert_eq!(
        info.metadata.creation_date.as_deref(),
        Some("D:20260415093000Z")
    );
    assert_eq!(
        info.metadata.modified_date.as_deref(),
        Some("D:20260415094500Z")
    );
}

#[test]
fn missing_pdf_returns_useful_error() {
    let error = read_local_pdf_text(PathBuf::from("missing.pdf")).unwrap_err();
    match error {
        PdfError::MissingFile(path) => assert!(path.ends_with("missing.pdf")),
        other => panic!("unexpected error: {other:?}"),
    }
}

fn write_fixture_pdf() -> PathBuf {
    let tempdir = tempdir().unwrap();
    let pdf_path = tempdir.path().join("sample.pdf");
    let bytes = build_fixture_pdf();
    std::fs::write(&pdf_path, bytes).unwrap();
    tempdir.keep().join("sample.pdf")
}

fn build_fixture_pdf() -> Vec<u8> {
    let mut bytes = Vec::new();
    let mut offsets = Vec::new();

    push_line(&mut bytes, "%PDF-1.4");
    push_line(&mut bytes, "%\u{00E2}\u{00E3}\u{00CF}\u{00D3}");

    push_object(
        &mut bytes,
        &mut offsets,
        "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
    );
    push_object(
        &mut bytes,
        &mut offsets,
        "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
    );
    push_object(
        &mut bytes,
        &mut offsets,
        "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n",
    );
    push_object(
        &mut bytes,
        &mut offsets,
        "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
    );

    let stream = b"BT\n/F1 24 Tf\n72 720 Td\n(Hello from PDF) Tj\nET\n";
    let content_object = format!(
        "5 0 obj\n<< /Length {} >>\nstream\n{}endstream\nendobj\n",
        stream.len(),
        String::from_utf8_lossy(stream)
    );
    push_object(&mut bytes, &mut offsets, &content_object);

    push_object(
        &mut bytes,
        &mut offsets,
        "6 0 obj\n<< /Title (Invoice 2026) /Author (Paperless) /Subject (Metadata test) /Creator (Codex) /Producer (Unit Test) /Keywords (invoice, pdf) /CreationDate (D:20260415093000Z) /ModDate (D:20260415094500Z) >>\nendobj\n",
    );

    let xref_offset = bytes.len();
    push_line(&mut bytes, "xref");
    push_line(&mut bytes, "0 7");
    push_line(&mut bytes, "0000000000 65535 f ");
    for offset in offsets {
        push_line(&mut bytes, &format!("{offset:010} 00000 n "));
    }
    push_line(&mut bytes, "trailer << /Root 1 0 R /Info 6 0 R /Size 7 >>");
    push_line(&mut bytes, &format!("startxref\n{xref_offset}"));
    push_line(&mut bytes, "%%EOF");

    bytes
}

fn push_object(bytes: &mut Vec<u8>, offsets: &mut Vec<usize>, object: &str) {
    offsets.push(bytes.len());
    bytes.extend_from_slice(object.as_bytes());
}

fn push_line(bytes: &mut Vec<u8>, line: &str) {
    bytes.extend_from_slice(line.as_bytes());
    bytes.push(b'\n');
}