use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use base64::Engine as _;
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
fn safe_cli_path(p: &Path) -> std::path::PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ArtifactKind {
Image,
Audio,
Pdf,
Video,
Office,
Html,
Text,
}
pub struct ExtractedDoc {
pub text: String,
#[allow(dead_code)]
pub kind: ArtifactKind,
}
pub fn detect_artifact(path: &Path) -> ArtifactKind {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "tiff" | "tif" | "webp" | "bmp" | "gif" => ArtifactKind::Image,
"mp3" | "wav" | "m4a" | "flac" | "ogg" | "opus" => ArtifactKind::Audio,
"pdf" => ArtifactKind::Pdf,
"mp4" | "mkv" | "mov" | "webm" | "avi" => ArtifactKind::Video,
"docx" | "pptx" | "xlsx" | "odt" | "odp" | "ods" => ArtifactKind::Office,
"html" | "htm" => ArtifactKind::Html,
_ => ArtifactKind::Text,
}
}
fn mime_for(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tiff" | "tif" => "image/tiff",
_ => "image/png",
}
}
async fn ocr_endpoint(
client: &reqwest::Client,
ocr: &crate::config::OcrConfig,
path: &Path,
) -> std::io::Result<String> {
let bytes = std::fs::read(path)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
let data_url = format!("data:{};base64,{b64}", mime_for(path));
let body = serde_json::json!({
"model": ocr.model,
"messages": [{ "role": "user", "content": [
{ "type": "text", "text": "Transcribe all text in this image verbatim. Output only the text." },
{ "type": "image_url", "image_url": { "url": data_url } }
]}],
}).to_string();
let key = std::env::var("OCR_API_KEY").or_else(|_| std::env::var("OPENAI_API_KEY")).unwrap_or_default();
let mut req = client.post(format!("{}/chat/completions", ocr.base_url))
.header("content-type", "application/json");
if !key.is_empty() { req = req.header("authorization", format!("Bearer {key}")); }
let text = req.body(body).send().await.and_then(|r| r.error_for_status())
.map_err(|e| std::io::Error::other(format!("ocr endpoint failed: {e}")))?
.text().await.map_err(|e| std::io::Error::other(format!("ocr read failed: {e}")))?;
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(v.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("message"))
.and_then(|m| m.get("content")).and_then(|c| c.as_str()).unwrap_or("").to_string())
}
fn ocr_cli(cli: &str, path: &Path) -> std::io::Result<String> {
let out = std::process::Command::new(cli).arg(safe_cli_path(path)).arg("stdout").output()?;
if !out.status.success() {
return Err(std::io::Error::other(format!("ocr cli failed: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub async fn ocr_image(
client: &reqwest::Client,
ocr: &crate::config::OcrConfig,
path: &Path,
) -> std::io::Result<String> {
if !ocr.base_url.is_empty() {
match ocr_endpoint(client, ocr, path).await {
Ok(t) => return Ok(t),
Err(e) => { if ocr.cli.is_empty() { return Err(e); } eprintln!("kibble: ocr endpoint failed, trying cli: {e}"); }
}
}
if !ocr.cli.is_empty() {
return ocr_cli(&ocr.cli, path);
}
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, format!("no OCR backend configured for {}", path.display())))
}
async fn transcribe_endpoint(
client: &reqwest::Client,
tr: &crate::config::TranscribeConfig,
path: &Path,
) -> std::io::Result<String> {
let bytes = std::fs::read(path)?;
let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("audio").to_string();
let part = reqwest::multipart::Part::bytes(bytes).file_name(fname);
let form = reqwest::multipart::Form::new().text("model", tr.model.clone()).part("file", part);
let key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
let mut req = client.post(format!("{}/audio/transcriptions", tr.base_url));
if !key.is_empty() { req = req.header("authorization", format!("Bearer {key}")); }
let text = req.multipart(form).send().await.and_then(|r| r.error_for_status())
.map_err(|e| std::io::Error::other(format!("transcribe endpoint failed: {e}")))?
.text().await.map_err(|e| std::io::Error::other(format!("transcribe read failed: {e}")))?;
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(v.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
}
fn transcribe_cli(cli: &str, path: &Path) -> std::io::Result<String> {
let out = std::process::Command::new(cli).arg(safe_cli_path(path)).output()?;
if !out.status.success() {
return Err(std::io::Error::other(format!("transcribe cli failed: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub async fn transcribe_audio(
client: &reqwest::Client,
tr: &crate::config::TranscribeConfig,
path: &Path,
) -> std::io::Result<String> {
if !tr.base_url.is_empty() {
match transcribe_endpoint(client, tr, path).await {
Ok(t) => return Ok(t),
Err(e) => { if tr.cli.is_empty() { return Err(e); } eprintln!("kibble: transcribe endpoint failed, trying cli: {e}"); }
}
}
if !tr.cli.is_empty() {
return transcribe_cli(&tr.cli, path);
}
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, format!("no transcription backend configured for {}", path.display())))
}
pub async fn extract_one(
client: &reqwest::Client,
cfg: &crate::config::ExtractConfig,
path: &Path,
) -> std::io::Result<ExtractedDoc> {
let kind = detect_artifact(path);
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > cfg.max_bytes {
return Err(std::io::Error::other(format!("{} exceeds extract max_bytes {}", path.display(), cfg.max_bytes)));
}
}
let text = match kind {
ArtifactKind::Image => ocr_image(client, &cfg.ocr, path).await?,
ArtifactKind::Audio => transcribe_audio(client, &cfg.transcribe, path).await?,
ArtifactKind::Html => {
let html = std::fs::read_to_string(path).unwrap_or_default();
crate::web::extract_main_text(&html)
}
ArtifactKind::Text => std::fs::read_to_string(path).unwrap_or_default(),
ArtifactKind::Pdf => extract_pdf(client, cfg, path).await?,
ArtifactKind::Video => extract_video(client, cfg, path).await?,
ArtifactKind::Office => extract_office(cfg, path)?,
};
Ok(ExtractedDoc { text, kind })
}
fn collect_inputs(input: &Path, out: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
if input.is_dir() {
for entry in std::fs::read_dir(input)? {
collect_inputs(&entry?.path(), out)?;
}
} else if input.is_file() {
out.push(input.to_path_buf());
}
Ok(())
}
pub async fn run_extract(
repo_root: &Path,
input: &Path,
out_override: Option<&str>,
skip_unsupported: bool,
) -> std::io::Result<usize> {
let cfg = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let proxy = crate::net::resolve_proxy(cfg.network.proxy.as_deref(), |k| std::env::var(k).ok());
let client = crate::net::build_client(proxy.as_deref())
.map_err(|e| std::io::Error::other(format!("http client: {e}")))?;
let out_root = repo_root.join(out_override.unwrap_or(&cfg.extract.out));
let mut inputs = Vec::new();
collect_inputs(input, &mut inputs)?;
let mut count = 0;
for path in inputs {
match extract_one(&client, &cfg.extract, &path).await {
Ok(doc) => {
let rel = path.strip_prefix(input).unwrap_or(&path);
let rel = if rel.as_os_str().is_empty() {
std::path::Path::new(path.file_name().unwrap_or(path.as_os_str()))
} else {
rel
};
let dest = out_root.join(format!("{}.txt", rel.to_string_lossy()));
if let Some(p) = dest.parent() { std::fs::create_dir_all(p)?; }
std::fs::write(&dest, doc.text)?;
count += 1;
}
Err(e) => {
if skip_unsupported && e.kind() == std::io::ErrorKind::Unsupported {
eprintln!("kibble: skipping {}: {e}", path.display());
} else {
return Err(e);
}
}
}
}
Ok(count)
}
async fn pdf_render_ocr(
client: &reqwest::Client,
cfg: &crate::config::ExtractConfig,
path: &Path,
) -> std::io::Result<String> {
if cfg.pdf.render_cli.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::Unsupported,
format!("no pdf render backend for {}", path.display())));
}
let tmp = std::env::temp_dir().join(format!("kibble_pdf_{}_{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst)));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
let prefix = tmp.join("page");
let out = std::process::Command::new(&cfg.pdf.render_cli)
.arg("-png").arg(safe_cli_path(path)).arg(&prefix).output()?;
if !out.status.success() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(std::io::Error::other(format!("pdf render failed: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
let pngs: Vec<std::path::PathBuf> = match std::fs::read_dir(&tmp) {
Ok(rd) => {
let mut v: Vec<std::path::PathBuf> = rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("png"))
.collect();
v.sort();
v
}
Err(e) => {
let _ = std::fs::remove_dir_all(&tmp);
return Err(e);
}
};
if pngs.is_empty() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(std::io::Error::other(format!("pdf render produced no pages for {}", path.display())));
}
let mut parts = Vec::new();
for png in &pngs {
match ocr_image(client, &cfg.ocr, png).await {
Ok(t) => parts.push(t),
Err(e) => eprintln!("kibble: page ocr failed for {}: {e}", png.display()),
}
}
let _ = std::fs::remove_dir_all(&tmp);
if parts.is_empty() {
return Err(std::io::Error::other(format!("all {} page(s) failed OCR for {}", pngs.len(), path.display())));
}
Ok(parts.join("\n\n"))
}
pub async fn extract_pdf(
client: &reqwest::Client,
cfg: &crate::config::ExtractConfig,
path: &Path,
) -> std::io::Result<String> {
if let Some(t) = pdf_text_layer(path) {
if t.trim().chars().count() >= cfg.pdf.min_chars {
return Ok(t);
}
}
pdf_render_ocr(client, cfg, path).await
}
fn pdf_text_layer(path: &Path) -> Option<String> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pdf_extract::extract_text(path)));
std::panic::set_hook(prev);
match result {
Ok(Ok(t)) if !t.trim().is_empty() => Some(t),
_ => None,
}
}
pub async fn extract_video(
client: &reqwest::Client,
cfg: &crate::config::ExtractConfig,
path: &Path,
) -> std::io::Result<String> {
if cfg.video.cli.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::Unsupported,
format!("no video backend for {}", path.display())));
}
let tmp = std::env::temp_dir().join(format!("kibble_vid_{}_{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::SeqCst)));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
let audio = tmp.join("audio.wav");
let out = std::process::Command::new(&cfg.video.cli)
.arg("-y").arg("-i").arg(safe_cli_path(path)).arg("-vn").arg(&audio).output()?;
if !out.status.success() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(std::io::Error::other(format!("video audio-extract failed: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
if !audio.is_file() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(std::io::Error::other(format!("no audio extracted from {}", path.display())));
}
let text = transcribe_audio(client, &cfg.transcribe, &audio).await;
let _ = std::fs::remove_dir_all(&tmp);
text
}
pub fn extract_office(
cfg: &crate::config::ExtractConfig,
path: &Path,
) -> std::io::Result<String> {
if cfg.office.cli.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::Unsupported,
format!("no office backend for {}", path.display())));
}
let out = std::process::Command::new(&cfg.office.cli)
.arg(safe_cli_path(path)).arg("-t").arg("plain").output()?;
if !out.status.success() {
return Err(std::io::Error::other(format!("office convert failed: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ocr_via_endpoint() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 4096]; let _ = s.read(&mut b);
let body = r#"{"choices":[{"message":{"content":"OCR ENDPOINT TEXT"}}]}"#;
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = s.write_all(head.as_bytes()); let _ = s.write_all(body.as_bytes());
}
});
let dir = std::env::temp_dir().join(format!("kibble_ocr_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let img = dir.join("x.png"); std::fs::write(&img, b"\x89PNG fake bytes").unwrap();
let ocr = crate::config::OcrConfig { base_url: format!("http://127.0.0.1:{port}/v1"), model: "m".into(), cli: String::new() };
let client = crate::net::build_client(None).unwrap();
let text = ocr_image(&client, &ocr, &img).await.unwrap();
assert_eq!(text, "OCR ENDPOINT TEXT");
}
#[tokio::test]
async fn ocr_via_cli_fallback() {
let dir = std::env::temp_dir().join(format!("kibble_ocrcli_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let img = dir.join("y.png"); std::fs::write(&img, b"bytes").unwrap();
let stub = dir.join("stub_ocr.sh");
std::fs::write(&stub, "#!/bin/sh\necho 'STUB OCR OUT'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&stub).status().unwrap();
let ocr = crate::config::OcrConfig { base_url: String::new(), model: String::new(), cli: stub.to_string_lossy().into_owned() };
let client = crate::net::build_client(None).unwrap();
let text = ocr_image(&client, &ocr, &img).await.unwrap();
assert!(text.contains("STUB OCR OUT"));
}
#[tokio::test]
async fn ocr_endpoint_fail_falls_back_to_cli() {
let dir = std::env::temp_dir().join(format!("kibble_ocrfb_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let img = dir.join("z.png"); std::fs::write(&img, b"bytes").unwrap();
let stub = dir.join("stub_ocr2.sh");
std::fs::write(&stub, "#!/bin/sh\necho 'FALLBACK OCR'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&stub).status().unwrap();
let ocr = crate::config::OcrConfig {
base_url: "http://127.0.0.1:1/v1".into(), model: "m".into(),
cli: stub.to_string_lossy().into_owned(),
};
let client = crate::net::build_client(None).unwrap();
let text = ocr_image(&client, &ocr, &img).await.unwrap();
assert!(text.contains("FALLBACK OCR"), "expected cli fallback, got: {text}");
}
#[tokio::test]
async fn transcribe_via_endpoint() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 4096]; let _ = s.read(&mut b);
let body = r#"{"text":"TRANSCRIBED ENDPOINT TEXT"}"#;
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = s.write_all(head.as_bytes()); let _ = s.write_all(body.as_bytes());
}
});
let dir = std::env::temp_dir().join(format!("kibble_tr_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let aud = dir.join("a.mp3"); std::fs::write(&aud, b"audio bytes").unwrap();
let tr = crate::config::TranscribeConfig { base_url: format!("http://127.0.0.1:{port}/v1"), model: "whisper-1".into(), cli: String::new() };
let client = crate::net::build_client(None).unwrap();
let text = transcribe_audio(&client, &tr, &aud).await.unwrap();
assert_eq!(text, "TRANSCRIBED ENDPOINT TEXT");
}
#[tokio::test]
async fn transcribe_via_cli_fallback() {
let dir = std::env::temp_dir().join(format!("kibble_trcli_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let aud = dir.join("a.wav"); std::fs::write(&aud, b"x").unwrap();
let stub = dir.join("stub_tr.sh");
std::fs::write(&stub, "#!/bin/sh\necho 'STUB TRANSCRIPT'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&stub).status().unwrap();
let tr = crate::config::TranscribeConfig { base_url: String::new(), model: String::new(), cli: stub.to_string_lossy().into_owned() };
let client = crate::net::build_client(None).unwrap();
let text = transcribe_audio(&client, &tr, &aud).await.unwrap();
assert!(text.contains("STUB TRANSCRIPT"));
}
#[tokio::test]
async fn run_extract_stages_image_and_text() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 8192]; let _ = s.read(&mut b);
let body = r#"{"choices":[{"message":{"content":"IMG TEXT"}}]}"#;
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = s.write_all(head.as_bytes()); let _ = s.write_all(body.as_bytes());
}
});
let root = std::env::temp_dir().join(format!("kibble_runext_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("in")).unwrap();
std::fs::write(root.join("in/pic.png"), b"img").unwrap();
std::fs::write(root.join("in/note.md"), "plain note text").unwrap();
std::fs::write(root.join(crate::config::CONFIG_FILE),
format!("[extract.ocr]\nbase_url=\"http://127.0.0.1:{port}/v1\"\nmodel=\"m\"\n")).unwrap();
let n = run_extract(&root, &root.join("in"), None, false).await.unwrap();
assert_eq!(n, 2);
assert_eq!(std::fs::read_to_string(root.join("data/extracted/pic.png.txt")).unwrap(), "IMG TEXT");
assert_eq!(std::fs::read_to_string(root.join("data/extracted/note.md.txt")).unwrap(), "plain note text");
}
#[tokio::test]
async fn run_extract_single_file_uses_filename() {
let root = std::env::temp_dir().join(format!("kibble_extsingle_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("solo.md"), "just one file").unwrap();
std::fs::write(root.join(crate::config::CONFIG_FILE), "").unwrap();
let n = run_extract(&root, &root.join("solo.md"), None, false).await.unwrap();
assert_eq!(n, 1);
assert_eq!(std::fs::read_to_string(root.join("data/extracted/solo.md.txt")).unwrap(), "just one file");
}
#[test]
fn pdf_text_layer_extracts_embedded_text() {
let dir = std::env::temp_dir().join(format!("kibble_pdftxt_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let pdf = dir.join("hello.pdf");
let bytes: &[u8] = b"\
%PDF-1.4\n\
1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n\
2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n\
3 0 obj\n<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources <</Font <</F1 5 0 R>>>>>>\nendobj\n\
4 0 obj\n<</Length 57>>\nstream\n\
BT /F1 12 Tf 100 700 Td (Hello PDF text layer here) Tj ET\n\
endstream\nendobj\n\
5 0 obj\n<</Type /Font /Subtype /Type1 /BaseFont /Helvetica>>\nendobj\n\
xref\n0 6\n\
0000000000 65535 f \n\
0000000009 00000 n \n\
0000000056 00000 n \n\
0000000111 00000 n \n\
0000000231 00000 n \n\
0000000336 00000 n \n\
trailer\n<</Size 6 /Root 1 0 R>>\n\
startxref\n404\n\
%%EOF\n";
std::fs::write(&pdf, bytes).unwrap();
let out = pdf_text_layer(&pdf);
assert!(out.is_some(), "expected text-layer extraction");
assert!(out.unwrap().contains("Hello PDF"), "expected the embedded text");
}
#[tokio::test]
async fn extract_pdf_renders_and_ocrs_when_no_text_layer() {
let dir = std::env::temp_dir().join(format!("kibble_pdfren_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let pdf = dir.join("scan.pdf"); std::fs::write(&pdf, b"not a real pdf text layer").unwrap();
let render = dir.join("stub_render.sh");
std::fs::write(&render, "#!/bin/sh\nlast=\"\"\nfor a in \"$@\"; do last=\"$a\"; done\nprintf 'fakepng' > \"${last}-1.png\"\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&render).status().unwrap();
let ocr = dir.join("stub_ocr.sh");
std::fs::write(&ocr, "#!/bin/sh\necho 'OCR PAGE TEXT'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&ocr).status().unwrap();
let cfg = crate::config::ExtractConfig {
pdf: crate::config::PdfConfig { render_cli: render.to_string_lossy().into_owned(), min_chars: 50 },
ocr: crate::config::OcrConfig { base_url: String::new(), model: String::new(), cli: ocr.to_string_lossy().into_owned() },
..Default::default()
};
let client = crate::net::build_client(None).unwrap();
let text = extract_pdf(&client, &cfg, &pdf).await.unwrap();
assert!(text.contains("OCR PAGE TEXT"), "expected rendered-page OCR text, got: {text}");
}
#[test]
fn pdf_text_layer_returns_none_on_bad_pdf_without_panicking() {
let dir = std::env::temp_dir().join(format!("kibble_pdftext_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("bad.pdf");
std::fs::write(&f, b"%PDF-1.4\n garbage not a real pdf body").unwrap();
assert!(pdf_text_layer(&f).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn extract_pdf_errors_when_all_pages_fail_ocr() {
let dir = std::env::temp_dir().join(format!("kibble_pdfallfail_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let pdf = dir.join("scan.pdf"); std::fs::write(&pdf, b"no text layer here").unwrap();
let render = dir.join("stub_render.sh");
std::fs::write(&render, "#!/bin/sh\nlast=\"\"\nfor a in \"$@\"; do last=\"$a\"; done\nprintf 'fakepng' > \"${last}-1.png\"\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&render).status().unwrap();
let ocr = dir.join("failing_ocr.sh");
std::fs::write(&ocr, "#!/bin/sh\nexit 1\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&ocr).status().unwrap();
let cfg = crate::config::ExtractConfig {
pdf: crate::config::PdfConfig { render_cli: render.to_string_lossy().into_owned(), min_chars: 50 },
ocr: crate::config::OcrConfig { base_url: String::new(), model: String::new(), cli: ocr.to_string_lossy().into_owned() },
..Default::default()
};
let client = crate::net::build_client(None).unwrap();
assert!(extract_pdf(&client, &cfg, &pdf).await.is_err(), "all-pages-fail must error, not return empty");
}
#[tokio::test]
async fn extract_video_pulls_audio_and_transcribes() {
let dir = std::env::temp_dir().join(format!("kibble_vid_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let vid = dir.join("clip.mp4"); std::fs::write(&vid, b"fake video bytes").unwrap();
let ff = dir.join("stub_ffmpeg.sh");
std::fs::write(&ff, "#!/bin/sh\nlast=\"\"\nfor a in \"$@\"; do last=\"$a\"; done\nprintf 'fakewav' > \"$last\"\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&ff).status().unwrap();
let tr = dir.join("stub_tr.sh");
std::fs::write(&tr, "#!/bin/sh\necho 'VIDEO TRANSCRIPT'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&tr).status().unwrap();
let cfg = crate::config::ExtractConfig {
video: crate::config::VideoConfig { cli: ff.to_string_lossy().into_owned() },
transcribe: crate::config::TranscribeConfig { base_url: String::new(), model: String::new(), cli: tr.to_string_lossy().into_owned() },
..Default::default()
};
let client = crate::net::build_client(None).unwrap();
let text = extract_video(&client, &cfg, &vid).await.unwrap();
assert!(text.contains("VIDEO TRANSCRIPT"), "expected transcription of extracted audio, got: {text}");
}
#[test]
fn extract_office_converts_to_text() {
let dir = std::env::temp_dir().join(format!("kibble_off_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let doc = dir.join("report.docx"); std::fs::write(&doc, b"fake docx bytes").unwrap();
let pd = dir.join("stub_pandoc.sh");
std::fs::write(&pd, "#!/bin/sh\necho 'OFFICE PLAIN TEXT'\n").unwrap();
std::process::Command::new("chmod").arg("+x").arg(&pd).status().unwrap();
let cfg = crate::config::ExtractConfig {
office: crate::config::OfficeConfig { cli: pd.to_string_lossy().into_owned() },
..Default::default()
};
let text = extract_office(&cfg, &doc).unwrap();
assert!(text.contains("OFFICE PLAIN TEXT"), "expected converted office text, got: {text}");
}
#[test]
fn safe_cli_path_makes_absolute() {
let dir = std::env::temp_dir().join(format!("kibble_safearg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("-dashname.txt"); std::fs::write(&f, b"x").unwrap();
let safe = safe_cli_path(&f);
assert!(safe.is_absolute(), "safe_cli_path must yield an absolute path");
assert!(!safe.to_string_lossy().starts_with('-'), "must not start with '-'");
}
#[test]
fn detects_kinds_by_extension() {
assert_eq!(detect_artifact(Path::new("a/scan.PNG")), ArtifactKind::Image);
assert_eq!(detect_artifact(Path::new("a/clip.mp3")), ArtifactKind::Audio);
assert_eq!(detect_artifact(Path::new("a/doc.pdf")), ArtifactKind::Pdf);
assert_eq!(detect_artifact(Path::new("a/v.mkv")), ArtifactKind::Video);
assert_eq!(detect_artifact(Path::new("a/r.docx")), ArtifactKind::Office);
assert_eq!(detect_artifact(Path::new("a/page.html")), ArtifactKind::Html);
assert_eq!(detect_artifact(Path::new("a/notes.md")), ArtifactKind::Text);
assert_eq!(detect_artifact(Path::new("a/unknownext.xyz")), ArtifactKind::Text);
}
}