1#![allow(
8 clippy::cast_possible_truncation,
9 clippy::cast_possible_wrap,
10 clippy::cast_precision_loss,
11 clippy::cast_sign_loss
12)]
13use std::fmt::Write as _;
14use std::path::Path;
15
16use crate::db::FileRow;
17
18use anyhow::{Context, Result};
19use sha2::{Digest, Sha256};
20
21use super::App;
22
23#[derive(Clone)]
25pub enum OcrUpdate {
26 Stage(String),
29 Progress(usize, usize, usize),
31 Done(std::result::Result<(String, Vec<(usize, String)>), String>),
34}
35
36#[derive(Clone)]
38pub enum OcrBackend {
39 Router(crate::provider::openrouter::OpenRouter, String),
41 Ollama(reqwest::Client, String),
44}
45
46impl OcrBackend {
47 async fn transcribe(&self, png: &[u8]) -> anyhow::Result<String> {
48 self.transcribe_image(png, "image/png").await
49 }
50
51 async fn describe(&self, bytes: &[u8], mime: &str) -> anyhow::Result<String> {
54 match self {
55 Self::Router(provider, model) => {
56 use base64::Engine;
57 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
58 let url = format!("data:{mime};base64,{b64}");
59 provider.describe_image(model, &url).await
60 }
61 Self::Ollama(client, model) => {
62 use base64::Engine;
63 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
64 let resp = client
65 .post("http://127.0.0.1:11434/api/generate")
66 .timeout(std::time::Duration::from_mins(10))
67 .json(&serde_json::json!({
68 "model": model,
69 "prompt": "Describe this image so another AI model can reason about \
70 it without seeing it. Cover: what it is (screenshot, chart, \
71 photo, diagram…), overall layout and structure, the key \
72 entities and how they relate, ALL visible text verbatim, \
73 and any notable visual details. Be thorough but do not \
74 speculate beyond what is visible.",
75 "images": [b64],
76 "stream": false,
77 "options": { "num_ctx": 8192 },
78 }))
79 .send()
80 .await
81 .map_err(|e| {
82 if e.is_timeout() {
83 anyhow::anyhow!("timeout after 600s")
84 } else if e.is_connect() {
85 anyhow::anyhow!("cannot reach ollama — is it running?")
86 } else {
87 e.into()
88 }
89 })?;
90 if resp.status().as_u16() == 404 {
91 anyhow::bail!("model '{model}' not pulled");
92 }
93 let v = resp.error_for_status()?.json::<serde_json::Value>().await?;
94 Ok(v.get("response")
95 .and_then(|r| r.as_str())
96 .unwrap_or("")
97 .to_string())
98 }
99 }
100 }
101
102 async fn transcribe_image(&self, bytes: &[u8], mime: &str) -> anyhow::Result<String> {
105 match self {
106 Self::Router(provider, model) => {
107 use base64::Engine;
108 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
109 let url = format!("data:{mime};base64,{b64}");
110 provider.ocr_page(model, &url).await
111 }
112 Self::Ollama(client, model) => {
113 use base64::Engine;
114 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
115 let resp = client
116 .post("http://127.0.0.1:11434/api/generate")
117 .timeout(std::time::Duration::from_mins(10))
118 .json(&ollama_ocr_body(model, &b64))
119 .send()
120 .await
121 .map_err(|e| {
122 if e.is_timeout() {
123 anyhow::anyhow!("timeout after 600s")
124 } else if e.is_connect() {
125 anyhow::anyhow!(
126 "cannot reach ollama at 127.0.0.1:11434 — is it running? (systemctl start ollama)"
127 )
128 } else {
129 e.into()
130 }
131 })?;
132 if resp.status().as_u16() == 404 {
133 anyhow::bail!(
134 "model '{model}' not pulled — cycle OCR engine to 'local' in /config"
135 );
136 }
137 let v = resp.error_for_status()?.json::<serde_json::Value>().await?;
138 Ok(v.get("response")
139 .and_then(|r| r.as_str())
140 .unwrap_or("")
141 .to_string())
142 }
143 }
144 }
145}
146
147fn is_uuid_like(stem: &str) -> bool {
151 let hex = |s: &str| s.chars().all(|c| c.is_ascii_hexdigit());
152 let parts: Vec<&str> = stem.split('-').collect();
153 parts.len() == 5
154 && parts[0].len() == 8
155 && hex(parts[0])
156 && parts[1].len() == 4
157 && hex(parts[1])
158 && parts[2].len() == 4
159 && hex(parts[2])
160 && parts[3].len() == 4
161 && hex(parts[3])
162 && parts[4].len() == 12
163 && hex(parts[4])
164}
165
166fn clip_err(e: &str) -> String {
167 let mut s: String = e.chars().take(90).collect();
168 if s.len() < e.len() {
169 s.push('…');
170 }
171 s
172}
173
174fn ollama_ocr_body(model: &str, png_b64: &str) -> serde_json::Value {
177 serde_json::json!({
178 "model": model,
179 "prompt": crate::provider::openrouter::OCR_PROMPT,
180 "images": [png_b64],
181 "stream": false,
182 "options": { "num_ctx": 8192 },
185 })
186}
187
188async fn ocr_pdf_vlm(
195 backend: &OcrBackend,
196 path: &Path,
197 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
198 space_id: &str,
199 name: &str,
200 files_dir: &Path,
201) -> std::result::Result<(String, Vec<(usize, String)>), String> {
202 let stem = std::path::Path::new(name)
203 .file_stem()
204 .and_then(|s| s.to_str())
205 .unwrap_or(name);
206 let page_dir = files_dir.join(stem);
207 if let Err(e) = std::fs::create_dir_all(&page_dir) {
208 return Err(format!("error: ocr: {e}"));
209 }
210 ocr_pdf_vlm_in(backend, path, &page_dir, tx, space_id, name).await
211}
212
213async fn ocr_pdf_vlm_in(
214 backend: &OcrBackend,
215 path: &Path,
216 page_dir: &Path,
217 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
218 space_id: &str,
219 name: &str,
220) -> std::result::Result<(String, Vec<(usize, String)>), String> {
221 let _ = tx.send((
222 space_id.to_string(),
223 name.to_string(),
224 OcrUpdate::Stage("rendering pages (300 dpi)…".to_string()),
225 ));
226 let (pdf, dir) = (path.to_path_buf(), page_dir.to_path_buf());
227 let pages = tokio::task::spawn_blocking(move || {
228 crate::extract::render_pdf_pages("pdftoppm", &pdf, &dir, 300, false)
229 })
230 .await
231 .map_err(|e| format!("error: ocr: {e}"))?
232 .map_err(|e| match e {
233 crate::extract::OcrError::MissingTools => {
234 "scanned pdf — install poppler (pdftoppm) for ocr".to_string()
235 }
236 crate::extract::OcrError::Failed(m) => format!("error: ocr: {m}"),
237 })?;
238
239 let total = pages.len();
240 let _ = tx.send((
243 space_id.to_string(),
244 name.to_string(),
245 OcrUpdate::Progress(0, total, 0),
246 ));
247 let mut results: Vec<std::result::Result<String, String>> =
248 vec![Err("not transcribed".to_string()); total];
249 let mut set = tokio::task::JoinSet::new();
250 let spawn_page =
251 |set: &mut tokio::task::JoinSet<(usize, std::result::Result<String, String>)>, i: usize| {
252 let (backend, png) = (backend.clone(), pages[i].clone());
253 set.spawn(async move {
254 let Ok(bytes) = std::fs::read(&png) else {
255 return (i, Err("page image unreadable".to_string()));
256 };
257 let mut last = String::new();
258 for _ in 0..2 {
259 match backend.transcribe(&bytes).await {
260 Ok(text) => return (i, Ok(text)),
261 Err(e) => last = e.to_string(),
262 }
263 }
264 (i, Err(last))
265 });
266 };
267
268 let window = (16_usize).min(total);
272 let mut next = 0;
273 while next < window {
274 spawn_page(&mut set, next);
275 next += 1;
276 }
277 let mut done = 0;
278 let mut failed = 0;
279 while let Some(joined) = set.join_next().await {
280 let (i, r) = joined.unwrap_or_else(|_| (usize::MAX, Err("page task panicked".to_string())));
281 if r.is_err() {
282 failed += 1;
283 }
284 if let Some(slot) = results.get_mut(i) {
285 *slot = r;
286 }
287 done += 1;
288 let _ = tx.send((
289 space_id.to_string(),
290 name.to_string(),
291 OcrUpdate::Progress(done, total, failed),
292 ));
293 if next < total {
294 spawn_page(&mut set, next);
295 next += 1;
296 }
297 }
298 let _ = std::fs::create_dir_all(page_dir);
300 for (i, p) in pages.iter().enumerate() {
301 let stable = page_dir.join(format!("page-{}.png", i + 1));
302 let _ = std::fs::rename(p, &stable);
303 }
304 let errors: Vec<(usize, String)> = results
305 .iter()
306 .enumerate()
307 .filter_map(|(i, r)| r.as_ref().err().map(|e| (i, e.clone())))
308 .collect();
309 Ok((crate::extract::join_pages(&results), errors))
310}
311
312async fn ocr_image_vlm(
316 backend: &OcrBackend,
317 path: &Path,
318 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
319 space_id: &str,
320 name: &str,
321) -> std::result::Result<(String, Vec<(usize, String)>), String> {
322 let _ = tx.send((
323 space_id.to_string(),
324 name.to_string(),
325 OcrUpdate::Stage("transcribing image…".to_string()),
326 ));
327 let Ok(bytes) = std::fs::read(path) else {
328 return Err(format!("cannot read {name}"));
329 };
330 let ext = path
331 .extension()
332 .and_then(|e| e.to_str())
333 .unwrap_or("")
334 .to_lowercase();
335 let mime = match ext.as_str() {
336 "jpg" | "jpeg" => "image/jpeg",
337 "gif" => "image/gif",
338 "webp" => "image/webp",
339 "bmp" => "image/bmp",
340 _ => "image/png",
341 };
342 let _ = tx.send((
343 space_id.to_string(),
344 name.to_string(),
345 OcrUpdate::Progress(0, 1, 0),
346 ));
347 match backend.describe(&bytes, mime).await {
348 Ok(text) => {
349 let _ = tx.send((
350 space_id.to_string(),
351 name.to_string(),
352 OcrUpdate::Progress(1, 1, 0),
353 ));
354 Ok((text, Vec::new()))
355 }
356 Err(e) => {
357 let err = e.to_string();
358 Err(format!("error: ocr: {err}"))
359 }
360 }
361}
362
363impl App {
364 pub fn rescan_files(&mut self) {
371 let dir = self.space.files_dir(&self.active_space.name);
372 let known = self
373 .db
374 .list_files(&self.active_space.id)
375 .unwrap_or_default();
376 let mut seen: Vec<String> = Vec::new();
377 let mut ocr_jobs: Vec<(String, String, std::path::PathBuf)> = Vec::new();
378
379 let entries = std::fs::read_dir(&dir)
380 .map(|rd| rd.flatten().collect::<Vec<_>>())
381 .unwrap_or_default();
382 for entry in entries {
383 let path = entry.path();
384 if !path.is_file() {
385 continue;
386 }
387 let name = entry.file_name().to_string_lossy().to_string();
388 seen.push(name.clone());
389 let mtime = entry
390 .metadata()
391 .ok()
392 .and_then(|m| m.modified().ok())
393 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
394 .map_or(0, |d| d.as_secs() as i64);
395 let disk_size = entry.metadata().map_or(0, |m| m.len() as i64);
396 let existing = known.iter().find(|f| f.name == name);
397 if let Some(f) = existing
400 && f.size == disk_size
401 && f.mtime == mtime
402 && mtime != 0
403 {
404 if f.status.starts_with("ocr") && self.ocr_rx.is_none() {
407 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
408 }
409 continue;
410 }
411 let Ok(bytes) = std::fs::read(&path) else {
412 continue;
413 };
414 let hash = Sha256::digest(&bytes)
415 .iter()
416 .fold(String::new(), |mut h, b| {
417 let _ = write!(h, "{b:02x}");
418 h
419 });
420 if let Some(f) = existing.filter(|f| f.hash == hash)
421 && self.db.file_indexed(&f.id).unwrap_or(false)
422 {
423 let _ = self.db.set_file_mtime(&f.id, mtime);
426 if f.status.starts_with("ocr") && self.ocr_rx.is_none() {
427 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
428 }
429 continue;
430 }
431 let size = bytes.len() as i64;
435 let (status, chunks) = match crate::extract::extract_text(&path) {
436 Ok(text) if text.trim().is_empty() => {
437 let ext = std::path::Path::new(&name)
438 .extension()
439 .and_then(|e| e.to_str())
440 .unwrap_or("")
441 .to_lowercase();
442 if ext == "pdf" || crate::extract::is_image_ext(&ext) {
443 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
444 ("ocr…".to_string(), Vec::new())
445 } else {
446 ("no text (scanned?)".to_string(), Vec::new())
447 }
448 }
449 Ok(text) => ("ok".to_string(), crate::extract::chunk_lines(&text)),
450 Err(e) => (format!("error: {e}"), Vec::new()),
451 };
452 if let Ok(id) = self
453 .db
454 .upsert_file(&self.active_space.id, &name, &hash, size, &status)
455 {
456 let _ = self.db.set_file_chunks(&id, &chunks);
457 let _ = self.db.set_file_mtime(&id, mtime);
458 }
459 }
460 for gone in known.iter().filter(|f| !seen.contains(&f.name)) {
461 let _ = self.db.delete_file(&gone.id);
462 }
463 self.start_ocr(ocr_jobs);
464 self.start_embedding();
467 self.files_cache = self
468 .db
469 .list_files(&self.active_space.id)
470 .unwrap_or_default();
471 }
472
473 pub fn start_ocr(&mut self, jobs: Vec<(String, String, std::path::PathBuf)>) {
477 if jobs.is_empty() || self.ocr_rx.is_some() {
478 return;
479 }
480 let backend = self.ocr_backend();
481 let files_dir = self.space.files_dir(&self.active_space.name);
482 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
483 self.ocr_rx = Some(rx);
484 if let Some(backend) = backend {
485 tokio::spawn(async move {
486 for (space_id, name, path) in jobs {
487 let is_image = path
488 .extension()
489 .and_then(|e| e.to_str())
490 .is_some_and(crate::extract::is_image_ext);
491 let result = if is_image {
492 ocr_image_vlm(&backend, &path, &tx, &space_id, &name).await
493 } else {
494 ocr_pdf_vlm(&backend, &path, &tx, &space_id, &name, &files_dir).await
495 };
496 if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
497 return;
498 }
499 }
500 });
501 return;
502 }
503 tokio::task::spawn_blocking(move || {
504 for (space_id, name, path) in jobs {
505 let is_image = path
506 .extension()
507 .and_then(|e| e.to_str())
508 .is_some_and(crate::extract::is_image_ext);
509 if is_image {
510 let _ = tx.send((
513 space_id,
514 name,
515 OcrUpdate::Done(Err("no vlm backend for image ocr".to_string())),
516 ));
517 continue;
518 }
519 let progress_tx = tx.clone();
520 let (sid, fname) = (space_id.clone(), name.clone());
521 let progress = move |done: usize, total: usize| {
522 let _ = progress_tx.send((
523 sid.clone(),
524 fname.clone(),
525 OcrUpdate::Progress(done, total, 0),
526 ));
527 };
528 let result = match crate::extract::ocr_pdf(&path, &progress) {
529 Ok(text) => Ok((text, Vec::new())),
530 Err(crate::extract::OcrError::MissingTools) => {
531 Err("scanned pdf — install tesseract + poppler for ocr".to_string())
532 }
533 Err(crate::extract::OcrError::Failed(e)) => Err(format!("error: ocr: {e}")),
534 };
535 if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
536 return;
537 }
538 }
539 });
540 }
541
542 pub fn ocr_backend(&self) -> Option<OcrBackend> {
545 if self.ocr_engine == "local" {
546 let model = self.local_ocr_model.trim();
547 let model = if model.is_empty() { "glm-ocr" } else { model };
548 return Some(OcrBackend::Ollama(
549 reqwest::Client::new(),
550 model.to_string(),
551 ));
552 }
553 if self.vlm_ocr_enabled() {
554 let model = self.ocr_model.trim().to_string();
555 return self
556 .resolve_model_backend(&model)
557 .map(|(p, raw_model)| OcrBackend::Router(p, raw_model));
558 }
559 None
560 }
561
562 pub fn ocr_local_install(&mut self, arg: &str) {
567 if self.ocr_pull_rx.is_some() {
568 self.push_status("an OCR model pull is already running".to_string());
569 return;
570 }
571 let model = if arg.is_empty() {
572 "glm-ocr".to_string()
573 } else {
574 arg.to_string()
575 };
576 self.local_ocr_model.clone_from(&model);
577 let _ = self.db.set_setting("local_ocr_model", &model);
578 #[cfg(test)]
582 {
583 self.ocr_engine = "local".to_string();
584 let _ = self.db.set_setting("ocr_engine", "local");
585 self.push_status(format!("(test) local OCR: {model}"));
586 }
587 #[cfg(not(test))]
588 {
589 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
590 self.ocr_pull_rx = Some(rx);
591 self.push_status(format!(
592 "pulling {model} via ollama… (keeps running in background)"
593 ));
594 tokio::spawn(async move {
595 let result = match tokio::process::Command::new("ollama")
596 .args(["pull", &model])
597 .output()
598 .await
599 {
600 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
601 Err("ollama not installed — get it from https://ollama.com (pacman -S ollama), then retry".to_string())
602 }
603 Err(e) => Err(format!("ollama pull failed: {e}")),
604 Ok(out) if !out.status.success() => {
605 let err = String::from_utf8_lossy(&out.stderr);
606 let hint = if err.contains("could not connect") || err.contains("connection refused") {
607 " — is the ollama server running? (systemctl start ollama, or `ollama serve`)"
608 } else {
609 ""
610 };
611 Err(format!("ollama pull failed: {}{hint}", err.trim()))
612 }
613 Ok(_) => Ok(model),
614 };
615 let _ = tx.send(result);
616 });
617 }
618 }
619
620 pub fn on_ocr_pull(&mut self, r: Option<Result<String, String>>) {
622 let Some(result) = r else {
623 self.ocr_pull_rx = None;
624 return;
625 };
626 self.ocr_pull_rx = None;
627 match result {
628 Ok(model) => {
629 self.ocr_engine = "local".to_string();
630 let _ = self.db.set_setting("ocr_engine", "local");
631 self.push_status(format!(
632 "local OCR ready: {model} via ollama — Ctrl+O a file in /files to re-run it"
633 ));
634 }
635 Err(e) => self.push_status(e),
636 }
637 }
638
639 pub fn reextract_file(&mut self, name: &str) {
643 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
644 return;
645 };
646 let _ = self.db.set_file_chunks(&f.id, &[]);
647 let _ = self
650 .db
651 .upsert_file(&self.active_space.id, &f.name, "", 0, "re-extracting");
652 self.push_status(format!("re-extracting: {}", f.name));
653 self.rescan_files();
654 }
655
656 pub fn reocr_file(&mut self, name: &str) {
661 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
662 return;
663 };
664 let ext = std::path::Path::new(&f.name)
665 .extension()
666 .and_then(|e| e.to_str())
667 .unwrap_or("")
668 .to_lowercase();
669 if ext != "pdf" && !crate::extract::is_image_ext(&ext) {
670 self.push_status(format!("only PDFs and images support OCR: {}", f.name));
671 return;
672 }
673 let path = self.space.files_dir(&self.active_space.name).join(&f.name);
674 self.ocr_rx = None;
676 let _ = self.db.set_file_status(&f.id, "ocr…");
677 self.start_ocr(vec![(self.active_space.id.clone(), f.name.clone(), path)]);
678 self.files_cache = self
679 .db
680 .list_files(&self.active_space.id)
681 .unwrap_or_default();
682 self.push_status(format!("ocr queued: {}", f.name));
683 }
684
685 pub fn start_embedding(&mut self) {
689 if self.embed_rx.is_some() {
690 return;
691 }
692 let model = self.embedding_model.trim().to_string();
693 if model.is_empty() {
694 return;
695 }
696 let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
697 return;
698 };
699 let space_id = self.active_space.id.clone();
700 let Ok(missing) = self.db.files_missing_embeddings(&space_id) else {
701 return;
702 };
703 let Some(file_id) = missing.first().cloned() else {
704 return;
705 };
706 let chunks = self.db.file_chunk_texts(&file_id).unwrap_or_default();
707 if chunks.is_empty() {
708 return;
709 }
710 let Ok(handle) = tokio::runtime::Handle::try_current() else {
713 return;
714 };
715 let _ = self.db.set_file_status(&file_id, "embedding…");
716 if space_id == self.active_space.id {
717 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
718 }
719 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
720 self.embed_rx = Some(rx);
721 handle.spawn(async move {
722 let mut out: Vec<(i64, Vec<f32>)> = Vec::with_capacity(chunks.len());
723 let mut err = None;
724 for batch in chunks.chunks(64) {
725 let inputs: Vec<String> = batch.iter().map(|(_, t)| t.clone()).collect();
726 match provider.embed(&raw_model, inputs).await {
727 Ok(vecs) => out.extend(batch.iter().zip(vecs).map(|((seq, _), v)| (*seq, v))),
728 Err(e) => {
729 err = Some(e.to_string());
730 break;
731 }
732 }
733 }
734 let result = match err {
735 Some(e) => Err(e),
736 None => Ok(out),
737 };
738 let _ = tx.send((space_id, file_id, result));
739 });
740 }
741
742 pub fn on_embed_done(&mut self, r: Option<crate::app::EmbedMsg>) {
747 let Some((space_id, file_id, result)) = r else {
748 self.embed_rx = None;
749 return;
750 };
751 self.embed_rx = None;
752 match result {
753 Ok(vecs) => {
754 let _ = self.db.set_chunk_embeddings(&file_id, &vecs);
755 let _ = self.db.set_file_status(&file_id, "ok");
756 self.start_embedding();
757 }
758 Err(e) => {
759 let _ = self.db.set_file_status(&file_id, "ok");
760 self.push_status(format!("embedding failed: {e}"));
761 }
762 }
763 if space_id == self.active_space.id {
764 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
765 }
766 }
767
768 pub fn on_ocr_done(&mut self, r: Option<(String, String, OcrUpdate)>) {
771 let Some((space_id, name, update)) = r else {
772 self.ocr_rx = None;
773 self.rescan_files();
777 return;
778 };
779 let Ok(files) = self.db.list_files(&space_id) else {
780 return;
781 };
782 let Some(f) = files.iter().find(|f| f.name == name) else {
783 return; };
785 if !f.status.starts_with("ocr") {
786 return; }
788 match update {
789 OcrUpdate::Stage(s) => {
790 let _ = self.db.set_file_status(&f.id, &format!("ocr: {s}"));
792 if space_id == self.active_space.id {
793 self.push_status(format!("ocr {name}: {s}"));
794 }
795 }
796 OcrUpdate::Progress(done, total, failed) => {
797 let tail = if failed > 0 {
798 format!(" ({failed} failed)")
799 } else {
800 String::new()
801 };
802 let _ = self
803 .db
804 .set_file_status(&f.id, &format!("ocr {done}/{total}{tail}"));
805 if space_id == self.active_space.id {
806 self.push_status(format!("ocr {name}: {done}/{total} pages{tail}"));
807 }
808 }
809 OcrUpdate::Done(Ok((text, errors))) if text.trim().is_empty() => {
810 let status = match errors.first() {
812 Some((i, e)) => {
813 format!("all pages failed (p{}: {})", i + 1, clip_err(e))
814 }
815 None => "no text (ocr found nothing)".to_string(),
816 };
817 let _ = self.db.set_file_status(&f.id, &status);
818 if space_id == self.active_space.id {
819 self.push_status(format!("ocr {name}: {status}"));
820 }
821 }
822 OcrUpdate::Done(Ok((text, errors))) => {
823 let _ = self
824 .db
825 .set_file_chunks(&f.id, &crate::extract::chunk_lines(&text));
826 let status = match errors.first() {
827 None => "ok".to_string(),
828 Some((i, e)) => format!(
829 "ok — {} page{} failed (p{}: {})",
830 errors.len(),
831 if errors.len() == 1 { "" } else { "s" },
832 i + 1,
833 clip_err(e),
834 ),
835 };
836 let _ = self.db.set_file_status(&f.id, &status);
837
838 if let Some(new_name) = Self::descriptive_paste_name(f, &text) {
840 let dir = self.space.files_dir(&self.active_space.name);
841 let old_path = dir.join(&f.name);
842 let new_path = dir.join(&new_name);
843 if old_path.exists() && std::fs::rename(&old_path, &new_path).is_ok() {
844 let _ = self.db.rename_file(&f.id, &new_name);
845 let _ = self
846 .db
847 .replace_file_ref_in_messages(&space_id, &f.name, &new_name);
848 if space_id == self.active_space.id {
849 self.push_status(format!("ocr done: {new_name}"));
850 }
851 } else if space_id == self.active_space.id {
853 self.push_status(format!("ocr done: {name}"));
854 }
855 } else if space_id == self.active_space.id {
856 self.push_status(format!("ocr done: {name}"));
857 }
858 }
859 OcrUpdate::Done(Err(msg)) => {
860 let _ = self.db.set_file_status(&f.id, &msg);
861 if space_id == self.active_space.id {
862 self.push_status(format!("ocr {name}: {msg}"));
863 }
864 }
865 }
866 if space_id == self.active_space.id {
867 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
868 }
869 }
870
871 pub fn import_file(&mut self, path: &Path) -> Result<String> {
875 let name = path
876 .file_name()
877 .map(|n| n.to_string_lossy().to_string())
878 .filter(|n| !n.is_empty())
879 .context("path has no file name")?;
880 let dir = self.space.files_dir(&self.active_space.name);
881 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
882 std::fs::copy(path, dir.join(&name))
883 .with_context(|| format!("copying {} into the space", path.display()))?;
884 self.rescan_files();
885 Ok(name)
886 }
887
888 pub fn delete_file(&mut self, name: &str) -> Result<()> {
891 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
892 return Ok(());
893 };
894 let disk = self.space.files_dir(&self.active_space.name).join(&f.name);
895 if disk.exists() {
896 std::fs::remove_file(&disk).with_context(|| format!("removing {}", disk.display()))?;
897 }
898 self.db.delete_file(&f.id)?;
899 self.push_status(format!("removed {}", f.name));
900 self.rescan_files();
901 Ok(())
902 }
903
904 pub fn rename_file(&mut self, name: &str, new: &str) -> Result<()> {
909 if new.is_empty() || new == name {
910 return Ok(());
911 }
912 if new.contains(['/', '\\']) || new == "." || new == ".." {
913 anyhow::bail!("invalid name: {new}");
914 }
915 let dir = self.space.files_dir(&self.active_space.name);
916 if dir.join(new).exists() {
917 anyhow::bail!("{new} already exists");
918 }
919 std::fs::rename(dir.join(name), dir.join(new))
920 .with_context(|| format!("renaming {name} to {new}"))?;
921 self.rescan_files();
922 self.push_status(format!("renamed {name} to {new}"));
923 Ok(())
924 }
925
926 fn descriptive_paste_name(f: &FileRow, ocr_text: &str) -> Option<String> {
929 let stem = std::path::Path::new(&f.name).file_stem()?.to_str()?;
930 let ext = std::path::Path::new(&f.name).extension()?.to_str()?;
931 if !is_uuid_like(stem) {
933 return None;
934 }
935 let slug = Self::slug_from_ocr(ocr_text)?;
936 Some(format!("{stem}-{slug}.{ext}"))
937 }
938
939 fn slug_from_ocr(text: &str) -> Option<String> {
942 let words: Vec<&str> = text
943 .split_whitespace()
944 .filter(|w| {
945 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
946 w.len() > 2 && w.chars().any(char::is_alphanumeric)
947 })
948 .collect();
949 if words.is_empty() {
950 return None;
951 }
952 let slug: String = words
953 .iter()
954 .take(5)
955 .map(|w| {
956 w.trim_matches(|c: char| !c.is_alphanumeric())
957 .to_lowercase()
958 })
959 .collect::<Vec<_>>()
960 .join("_");
961 if slug.is_empty() { None } else { Some(slug) }
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::db::Db;
969 use crate::space::Space;
970
971 fn test_app() -> App {
972 let db = Db::open_in_memory().unwrap();
973 let root = std::env::temp_dir().join(format!("nexus-files-test-{}", uuid::Uuid::new_v4()));
974 std::fs::create_dir_all(root.join("spaces")).unwrap();
975 let space = Space { root };
976 App::new(db, Some("k"), space)
977 }
978
979 #[tokio::test]
980 async fn embedder_queue_backfills_chains_and_stops_on_error() {
981 let mut a = test_app();
982 let space = a.active_space.id.clone();
983 let id = a.db.upsert_file(&space, "b.txt", "h", 1, "ok").unwrap();
984 a.db.set_file_chunks(&id, &[("l".into(), "text".into())])
985 .unwrap();
986
987 let saved = a.backends.clone();
989 a.backends = crate::app::Backends::default();
990 a.start_embedding();
991 assert!(a.embed_rx.is_none());
992 a.backends = saved;
993
994 let m = std::mem::take(&mut a.embedding_model);
996 a.start_embedding();
997 assert!(a.embed_rx.is_none());
998 a.embedding_model = m;
999
1000 a.start_embedding();
1002 assert!(a.embed_rx.is_some());
1003 let files = a.db.list_files(&space).unwrap();
1004 assert!(
1005 files[0].status.starts_with("embedding"),
1006 "{}",
1007 files[0].status
1008 );
1009
1010 a.on_embed_done(Some((
1012 space.clone(),
1013 id.clone(),
1014 Ok(vec![(0, vec![1.0f32, 0.0])]),
1015 )));
1016 assert!(a.db.files_missing_embeddings(&space).unwrap().is_empty());
1017 assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1018
1019 a.db.set_file_chunks(&id, &[("l".into(), "new".into())])
1021 .unwrap();
1022 a.on_embed_done(Some((space.clone(), id.clone(), Err("offline".into()))));
1023 assert!(a.embed_rx.is_none());
1024 let (_, status) = a.drain_ui_events();
1025 assert!(status.contains("embedding failed"));
1026 assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1027 }
1028
1029 #[test]
1030 fn import_copies_extracts_and_indexes() {
1031 let mut a = test_app();
1032 let src = std::env::temp_dir().join(format!("nexus-src-{}.md", uuid::Uuid::new_v4()));
1033 std::fs::write(&src, "# quarterly report\nrevenue up").unwrap();
1034
1035 let name = a.import_file(&src).unwrap();
1036 assert_eq!(name, src.file_name().unwrap().to_string_lossy());
1037 assert_eq!(a.files_cache.len(), 1);
1038 assert_eq!(a.files_cache[0].status, "ok");
1039 assert!(a.space.files_dir(&a.active_space.name).join(&name).exists());
1041 let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1043 .unwrap();
1044 assert_eq!(hits.len(), 1);
1045 }
1046
1047 #[test]
1048 fn cold_cache_after_restore_reindexes_from_disk() {
1049 let root = std::env::temp_dir().join(format!("nexus-cold-{}", uuid::Uuid::new_v4()));
1053 std::fs::create_dir_all(root.join("spaces")).unwrap();
1054 let space = Space { root };
1055 let db = Db::open(&space.db_path()).unwrap();
1056 let mut a = App::new(db, Some("k"), space);
1057
1058 let src = std::env::temp_dir().join(format!("nexus-cold-src-{}.md", uuid::Uuid::new_v4()));
1059 std::fs::write(&src, "# report\nresilience is a property").unwrap();
1060 let name = a.import_file(&src).unwrap();
1061 let id = a.files_cache[0].id.clone();
1062 assert!(a.db.file_indexed(&id).unwrap());
1063 assert!(
1064 crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1065 .unwrap()
1066 .is_some()
1067 );
1068 let root = a.space.root.clone();
1071 drop(a);
1072
1073 let cache_path = root.join("cache.db");
1075 assert!(cache_path.exists());
1076 std::fs::remove_file(&cache_path).unwrap();
1077
1078 let mut a = App::new(
1079 Db::open(&root.join("nexus.db")).unwrap(),
1080 Some("k"),
1081 Space { root },
1082 );
1083 assert!(!a.db.file_indexed(&id).unwrap());
1084
1085 a.rescan_files();
1088 assert!(a.db.file_indexed(&id).unwrap());
1089 assert_eq!(
1090 crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1091 .unwrap()
1092 .as_deref(),
1093 Some("# report\nresilience is a property")
1094 );
1095 assert_eq!(a.db.list_files(&a.active_space.id).unwrap()[0].status, "ok");
1096 }
1097
1098 #[test]
1099 fn ollama_ocr_body_uses_native_generate_shape() {
1100 let body = ollama_ocr_body("glm-ocr", "QUFB");
1101 assert_eq!(body["model"], "glm-ocr");
1102 assert_eq!(body["stream"], false);
1103 assert_eq!(body["images"][0], "QUFB"); assert!(body["prompt"].as_str().unwrap().contains("furigana"));
1105 assert!(
1106 body.get("messages").is_none(),
1107 "must not be OpenAI chat shape"
1108 );
1109 }
1110
1111 #[test]
1112 fn ocr_backend_routes_by_engine() {
1113 let mut a = test_app();
1114 assert!(matches!(a.ocr_backend(), Some(OcrBackend::Router(..))));
1116 a.ocr_engine = "local".to_string();
1118 a.local_ocr_model = String::new(); match a.ocr_backend() {
1120 Some(OcrBackend::Ollama(_, model)) => assert_eq!(model, "glm-ocr"),
1121 other => panic!("expected ollama backend, got {}", other.is_some()),
1122 }
1123 a.ocr_engine = "tesseract".to_string();
1125 assert!(a.ocr_backend().is_none());
1126 a.ocr_engine = "auto".to_string();
1128 a.backends = crate::app::Backends::default();
1129 assert!(a.ocr_backend().is_none());
1130 }
1131
1132 #[tokio::test]
1133 async fn ocr_pull_success_switches_engine_to_local() {
1134 let mut a = test_app();
1135 a.on_ocr_pull(Some(Ok("glm-ocr".to_string())));
1136 assert_eq!(a.ocr_engine, "local");
1137 let (_, status) = a.drain_ui_events();
1138 assert!(status.contains("local OCR ready"));
1139 a.on_ocr_pull(Some(Err("ollama not installed — get it".to_string())));
1140 assert_eq!(a.ocr_engine, "local"); let (_, status) = a.drain_ui_events();
1142 assert!(status.contains("ollama not installed"));
1143 }
1144
1145 #[test]
1146 fn ocr_statuses_surface_stages_failures_and_reasons() {
1147 let mut a = test_app();
1148 let space = a.active_space.id.clone();
1149 let id =
1150 a.db.upsert_file(&space, "scan.pdf", "h", 1, "ocr…")
1151 .unwrap();
1152
1153 a.on_ocr_done(Some((
1155 space.clone(),
1156 "scan.pdf".into(),
1157 OcrUpdate::Stage("rendering pages (300 dpi)…".into()),
1158 )));
1159 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1160 assert_eq!(status, "ocr: rendering pages (300 dpi)…");
1161
1162 a.on_ocr_done(Some((
1164 space.clone(),
1165 "scan.pdf".into(),
1166 OcrUpdate::Progress(5, 10, 2),
1167 )));
1168 assert_eq!(
1169 a.db.list_files(&space).unwrap()[0].status,
1170 "ocr 5/10 (2 failed)"
1171 );
1172 assert!(a.last_status().contains("5/10 pages (2 failed)"));
1173
1174 a.on_ocr_done(Some((
1176 space.clone(),
1177 "scan.pdf".into(),
1178 OcrUpdate::Done(Ok((
1179 "[page 1]\ntext".to_string(),
1180 vec![
1181 (2, "timeout after 600s".to_string()),
1182 (4, "boom".to_string()),
1183 ],
1184 ))),
1185 )));
1186 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1187 assert_eq!(status, "ok — 2 pages failed (p3: timeout after 600s)");
1188
1189 let _ = a.db.set_file_status(&id, "ocr…");
1191 a.on_ocr_done(Some((
1192 space.clone(),
1193 "scan.pdf".into(),
1194 OcrUpdate::Done(Ok((
1195 String::new(),
1196 vec![(0, "cannot reach ollama at 127.0.0.1:11434 — is it running? (systemctl start ollama)".to_string())],
1197 ))),
1198 )));
1199 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1200 assert!(
1201 status.starts_with("all pages failed (p1: cannot reach ollama"),
1202 "{status}"
1203 );
1204 assert!(!status.starts_with("ocr"), "{status}");
1206 }
1207
1208 #[test]
1209 fn reextract_clears_stale_chunks_and_reindexes_from_disk() {
1210 let mut a = test_app();
1211 let dir = a.space.files_dir(&a.active_space.name);
1212 std::fs::create_dir_all(&dir).unwrap();
1213 std::fs::write(dir.join("doc.txt"), "real content on disk").unwrap();
1214 a.rescan_files();
1215 let id = a.files_cache[0].id.clone();
1216
1217 a.db.set_file_chunks(&id, &[("p1".into(), "garbage".into())])
1219 .unwrap();
1220
1221 a.reextract_file("doc.txt");
1222 assert!(
1223 a.last_status().contains("re-extracting"),
1224 "{}",
1225 a.last_status()
1226 );
1227 let texts = a.db.file_chunk_texts(&id).unwrap();
1228 assert_eq!(texts.len(), 1);
1229 assert!(texts[0].1.contains("real content"), "{texts:?}");
1230 assert_eq!(a.files_cache[0].status, "ok");
1231 }
1232
1233 #[test]
1234 fn rescan_picks_up_dropped_and_deleted_files() {
1235 let mut a = test_app();
1236 let dir = a.space.files_dir(&a.active_space.name);
1237 std::fs::create_dir_all(&dir).unwrap();
1238 std::fs::write(dir.join("dropped.txt"), "hello dropped").unwrap();
1239
1240 a.rescan_files();
1241 assert_eq!(a.files_cache.len(), 1);
1242 assert_eq!(a.files_cache[0].name, "dropped.txt");
1243
1244 std::fs::write(dir.join("dropped.txt"), "hello again").unwrap();
1246 a.rescan_files();
1247 assert_eq!(a.files_cache.len(), 1);
1248 std::fs::remove_file(dir.join("dropped.txt")).unwrap();
1249 a.rescan_files();
1250 assert!(a.files_cache.is_empty());
1251 }
1252
1253 #[test]
1254 fn empty_extraction_gets_no_text_status() {
1255 let mut a = test_app();
1256 let dir = a.space.files_dir(&a.active_space.name);
1257 std::fs::create_dir_all(&dir).unwrap();
1258 std::fs::write(dir.join("empty.txt"), " ").unwrap();
1259 a.rescan_files();
1260 assert_eq!(a.files_cache[0].status, "no text (scanned?)");
1261 }
1262
1263 #[test]
1264 fn rescan_skips_stat_unchanged_files_without_rehashing() {
1265 let mut a = test_app();
1266 let dir = a.space.files_dir(&a.active_space.name);
1267 std::fs::create_dir_all(&dir).unwrap();
1268 std::fs::write(dir.join("book.txt"), "big content").unwrap();
1269 a.rescan_files();
1270 let f = a.files_cache[0].clone();
1271 assert!(f.mtime > 0, "mtime recorded on index");
1272
1273 a.db.upsert_file(&a.active_space.id, "book.txt", "sentinel", f.size, "ok")
1276 .unwrap();
1277 a.rescan_files();
1278 assert_eq!(a.files_cache[0].hash, "sentinel");
1279
1280 std::fs::write(dir.join("book.txt"), "big content grew").unwrap();
1282 a.rescan_files();
1283 assert_ne!(a.files_cache[0].hash, "sentinel");
1284 assert_eq!(a.files_cache[0].status, "ok");
1285 }
1286
1287 #[test]
1288 fn rename_moves_disk_file_and_reindexes() {
1289 let mut a = test_app();
1290 let dir = a.space.files_dir(&a.active_space.name);
1291 std::fs::create_dir_all(&dir).unwrap();
1292 std::fs::write(dir.join("old.txt"), "searchable content").unwrap();
1293 std::fs::write(dir.join("taken.txt"), "x").unwrap();
1294 a.rescan_files();
1295
1296 assert!(a.rename_file("old.txt", "taken.txt").is_err());
1298 assert!(dir.join("old.txt").exists());
1299
1300 assert!(a.rename_file("old.txt", "../evil.txt").is_err());
1302
1303 a.rename_file("old.txt", "new.txt").unwrap();
1305 assert!(!dir.join("old.txt").exists());
1306 assert!(dir.join("new.txt").exists());
1307 assert!(a.files_cache.iter().any(|f| f.name == "new.txt"));
1308 }
1309
1310 #[test]
1311 fn delete_removes_disk_file_and_row() {
1312 let mut a = test_app();
1313 let dir = a.space.files_dir(&a.active_space.name);
1314 std::fs::create_dir_all(&dir).unwrap();
1315 std::fs::write(dir.join("gone.txt"), "bye").unwrap();
1316 a.rescan_files();
1317 a.delete_file("gone.txt").unwrap();
1318 assert!(a.files_cache.is_empty());
1319 assert!(!dir.join("gone.txt").exists());
1320 }
1321
1322 #[test]
1323 fn import_file_copies_and_indexes_typed_path() {
1324 let mut a = test_app();
1325 let src = std::env::temp_dir().join(format!("nexus-add-{}.txt", uuid::Uuid::new_v4()));
1326 std::fs::write(&src, "typed in").unwrap();
1327 let name = a.import_file(&src).unwrap();
1328 assert_eq!(a.files_cache.len(), 1);
1329 assert_eq!(a.files_cache[0].name, name);
1330
1331 assert!(
1333 a.import_file(std::path::Path::new("/definitely/not/a/file"))
1334 .is_err()
1335 );
1336 assert_eq!(a.files_cache.len(), 1);
1337 }
1338
1339 #[tokio::test]
1340 async fn rescan_marks_empty_pdf_ocr_and_spawns_batch() {
1341 let mut a = test_app();
1342 let dir = a.space.files_dir(&a.active_space.name);
1343 std::fs::create_dir_all(&dir).unwrap();
1344 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1345
1346 a.rescan_files();
1347 assert_eq!(a.files_cache[0].status, "ocr…");
1348 assert!(a.ocr_rx.is_some(), "an ocr batch should be in flight");
1349
1350 a.rescan_files();
1352 assert_eq!(a.files_cache[0].status, "ocr…");
1353 }
1354
1355 #[tokio::test]
1356 async fn rescan_requeues_stale_ocr_status_when_idle() {
1357 let mut a = test_app();
1358 let dir = a.space.files_dir(&a.active_space.name);
1359 std::fs::create_dir_all(&dir).unwrap();
1360 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1361 a.rescan_files();
1362
1363 a.ocr_rx = None;
1365 a.rescan_files();
1366 assert!(a.ocr_rx.is_some(), "stale ocr… should re-queue");
1367 }
1368
1369 #[test]
1370 fn on_ocr_done_ok_indexes_and_marks_ok() {
1371 let mut a = test_app();
1372 let id =
1373 a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1374 .unwrap();
1375 let _ = id;
1376 a.on_ocr_done(Some((
1377 a.active_space.id.clone(),
1378 "scan.pdf".to_string(),
1379 OcrUpdate::Done(Ok((
1380 "[page 1]\nquarterly revenue table".to_string(),
1381 Vec::new(),
1382 ))),
1383 )));
1384 assert_eq!(a.files_cache[0].status, "ok");
1385 let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1386 .unwrap();
1387 assert_eq!(hits.len(), 1);
1388 }
1389
1390 #[test]
1391 fn on_ocr_progress_updates_status_and_status_line() {
1392 let mut a = test_app();
1393 a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1394 .unwrap();
1395 a.on_ocr_done(Some((
1396 a.active_space.id.clone(),
1397 "scan.pdf".to_string(),
1398 OcrUpdate::Progress(3, 10, 0),
1399 )));
1400 assert_eq!(a.files_cache[0].status, "ocr 3/10");
1401 assert!(a.last_status().contains("3/10"), "{}", a.last_status());
1402
1403 a.on_ocr_done(Some((
1405 a.active_space.id.clone(),
1406 "scan.pdf".to_string(),
1407 OcrUpdate::Done(Ok(("[page 1]\nfound".to_string(), Vec::new()))),
1408 )));
1409 assert_eq!(a.files_cache[0].status, "ok");
1410 }
1411
1412 #[test]
1413 fn on_ocr_done_empty_and_err_statuses() {
1414 let mut a = test_app();
1415 a.db.upsert_file(&a.active_space.id, "blank.pdf", "h1", 9, "ocr…")
1416 .unwrap();
1417 a.db.upsert_file(&a.active_space.id, "bad.pdf", "h2", 9, "ocr…")
1418 .unwrap();
1419
1420 a.on_ocr_done(Some((
1421 a.active_space.id.clone(),
1422 "blank.pdf".to_string(),
1423 OcrUpdate::Done(Ok((String::new(), Vec::new()))),
1424 )));
1425 a.on_ocr_done(Some((
1426 a.active_space.id.clone(),
1427 "bad.pdf".to_string(),
1428 OcrUpdate::Done(Err(
1429 "scanned pdf — install tesseract + poppler for ocr".to_string()
1430 )),
1431 )));
1432
1433 let by_name = |a: &App, n: &str| {
1434 a.files_cache
1435 .iter()
1436 .find(|f| f.name == n)
1437 .unwrap()
1438 .status
1439 .clone()
1440 };
1441 assert_eq!(by_name(&a, "blank.pdf"), "no text (ocr found nothing)");
1442 assert_eq!(
1443 by_name(&a, "bad.pdf"),
1444 "scanned pdf — install tesseract + poppler for ocr"
1445 );
1446 }
1447
1448 #[test]
1449 fn on_ocr_done_for_inactive_space_writes_db_but_not_cache() {
1450 let mut a = test_app();
1451 let other = a.db.create_space("other").unwrap();
1452 a.db.upsert_file(&other.id, "scan.pdf", "h", 9, "ocr…")
1453 .unwrap();
1454
1455 a.on_ocr_done(Some((
1456 other.id.clone(),
1457 "scan.pdf".to_string(),
1458 OcrUpdate::Done(Ok(("found text".to_string(), Vec::new()))),
1459 )));
1460
1461 assert!(
1462 a.files_cache.is_empty(),
1463 "active-space cache must not show other space's file"
1464 );
1465 let rows = a.db.list_files(&other.id).unwrap();
1466 assert_eq!(rows[0].status, "ok");
1467
1468 a.on_ocr_done(Some((
1470 other.id.clone(),
1471 "gone.pdf".to_string(),
1472 OcrUpdate::Done(Ok(("x".to_string(), Vec::new()))),
1473 )));
1474 }
1475
1476 #[tokio::test]
1477 async fn on_ocr_done_none_clears_channel_and_requeues_stragglers() {
1478 let mut a = test_app();
1479 let dir = a.space.files_dir(&a.active_space.name);
1480 std::fs::create_dir_all(&dir).unwrap();
1481 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1483 a.rescan_files();
1484 assert!(a.ocr_rx.is_some());
1485 a.on_ocr_done(None);
1487 assert!(a.ocr_rx.is_some(), "straggler should re-queue on batch end");
1488 }
1489}