Skip to main content

nexus_core/app/
files.rs

1//! Space filesets: importing files into `spaces/<name>/files/`, keeping the
2//! db index in sync with the directory, and extracting searchable text.
3
4// Casts here are on bounded values: token counts, byte sizes, and
5// selection indices — never on unbounded input. JSON-derived indices in
6// provider/tools go through try_from instead.
7#![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/// A message from the background OCR batch about one file.
24#[derive(Clone)]
25pub enum OcrUpdate {
26    /// A human-readable phase ("rendering pages…") shown while nothing is
27    /// countable yet.
28    Stage(String),
29    /// (pages done, total pages, pages failed so far).
30    Progress(usize, usize, usize),
31    /// Final outcome: (extracted text, per-page errors as (index, reason)),
32    /// or a whole-document error message.
33    Done(std::result::Result<(String, Vec<(usize, String)>), String>),
34}
35
36/// Which service transcribes a rendered page image.
37#[derive(Clone)]
38pub enum OcrBackend {
39    /// `OpenRouter` vision model (`ocr_model`).
40    Router(crate::provider::openrouter::OpenRouter, String),
41    /// Local Ollama model via its native /api/generate endpoint — the
42    /// OpenAI-compatible route mishandles GLM-OCR's vision input.
43    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    /// Describe an image (not OCR — uses a description prompt so another model
52    /// can reason about the image content). For standalone space-file images.
53    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    /// Transcribe an image file with the given MIME type. For standalone images
103    /// (not PDF pages) that may be JPEG, PNG, etc.
104    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
147/// First ~90 chars of an error, so a page failure fits in the status column
148/// without swallowing the reason.
149/// A stem that looks like a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
150fn 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
174/// Request body for Ollama's native generate endpoint: raw base64 in
175/// `images`, not an OpenAI-style content part.
176fn 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        // Ollama defaults to 4096 ctx — page image tokens plus a dense page's
183        // transcription overflow that and silently clip the output.
184        "options": { "num_ctx": 8192 },
185    })
186}
187
188/// OCR a scanned PDF through a vision backend: render pages at 300 DPI color,
189/// transcribe up to 4 pages concurrently (one retry each), and join with
190/// `[page N]` markers — a page that fails twice becomes a `[page N: ocr
191/// failed]` marker instead of sinking the document.
192/// Rendered page PNGs are saved permanently to `<files_dir>/<pdf_stem>/` so
193/// the model can fetch them later via `files(action=pdf_page)`.
194async 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    // Show "0/N pages" immediately — on CPU the first page can take minutes,
241    // and a frozen "ocr…" reads as stuck.
242    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    // ponytail: 16 concurrent pages — the bottleneck is API latency, not
269    // local CPU, so a wider window reduces wall-clock time significantly.
270    // Tune this down if the backend rate-limits you.
271    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    // Rename pdftoppm output to stable page-<N>.png names
299    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
312/// OCR a standalone image file through a vision backend: read the file,
313/// transcribe it directly (no page rendering), return OCR text. Reuses the
314/// same `OcrUpdate` channel as `pdf_vlm` for status/progress.
315async 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    /// Sync the active space's files directory with the db: new or changed
365    /// files (by sha256) are re-extracted and re-indexed, rows for deleted
366    /// files are dropped, and `files_cache` is refreshed. Best-effort: a
367    /// single bad file gets an "error: …" status instead of failing the scan.
368    /// ponytail: runs synchronously on the UI task — extraction of a huge PDF
369    /// blocks a beat; move to a blocking task if that ever hurts.
370    #[allow(clippy::too_many_lines)]
371    pub fn rescan_files(&mut self) {
372        let dir = self.space.files_dir(&self.active_space.name);
373        let known = self
374            .db
375            .list_files(&self.active_space.id)
376            .unwrap_or_default();
377        let mut seen: Vec<String> = Vec::new();
378        let mut ocr_jobs: Vec<(String, String, std::path::PathBuf)> = Vec::new();
379
380        let entries = std::fs::read_dir(&dir)
381            .map(|rd| rd.flatten().collect::<Vec<_>>())
382            .unwrap_or_default();
383        for entry in entries {
384            let path = entry.path();
385            if !path.is_file() {
386                continue;
387            }
388            let name = entry.file_name().to_string_lossy().to_string();
389            seen.push(name.clone());
390            let mtime = entry
391                .metadata()
392                .ok()
393                .and_then(|m| m.modified().ok())
394                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
395                .map_or(0, |d| d.as_secs() as i64);
396            let disk_size = entry.metadata().map_or(0, |m| m.len() as i64);
397            let existing = known.iter().find(|f| f.name == name);
398            // Unchanged by stat: skip entirely — no read, no hash. This is what
399            // keeps /files and space switches snappy with big filesets.
400            if let Some(f) = existing
401                && f.size == disk_size
402                && f.mtime == mtime
403                && mtime != 0
404            {
405                // Stale "ocr…"/"ocr N/M" (app quit mid-OCR) re-queues once no
406                // batch is in flight.  Do not let the stat fast path hide a
407                // file imported by an older build that has no chunks yet.
408                if f.status.starts_with("ocr") {
409                    if self.ocr_rx.is_none() {
410                        ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
411                    }
412                    continue;
413                }
414                if self.db.file_has_chunks(&f.id).unwrap_or(false) {
415                    continue;
416                }
417            }
418            let Ok(bytes) = std::fs::read(&path) else {
419                continue;
420            };
421            let hash = Sha256::digest(&bytes)
422                .iter()
423                .fold(String::new(), |mut h, b| {
424                    let _ = write!(h, "{b:02x}");
425                    h
426                });
427            if let Some(f) = existing.filter(|f| f.hash == hash)
428                && self.db.file_indexed(&f.id).unwrap_or(false)
429                && self.db.file_has_chunks(&f.id).unwrap_or(false)
430            {
431                // Content unchanged (touched, or indexed before mtimes were
432                // tracked): just record the stat for next time.
433                let _ = self.db.set_file_mtime(&f.id, mtime);
434                if f.status.starts_with("ocr") && self.ocr_rx.is_none() {
435                    ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
436                }
437                continue;
438            }
439            // Cold cache (fresh restore, wiped cache.db): the durable row
440            // survives but this device's index state is gone — fall through
441            // to re-extract so chunks/embeddings rebuild here.
442            let size = bytes.len() as i64;
443            let (status, chunks) = match crate::extract::extract_text(&path) {
444                Ok(text) if text.trim().is_empty() => {
445                    let ext = std::path::Path::new(&name)
446                        .extension()
447                        .and_then(|e| e.to_str())
448                        .unwrap_or("")
449                        .to_lowercase();
450                    if ext == "pdf" || crate::extract::is_image_ext(&ext) {
451                        ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
452                        ("ocr…".to_string(), Vec::new())
453                    } else {
454                        (
455                            "no text (scanned?)".to_string(),
456                            crate::extract::metadata_chunks(&name),
457                        )
458                    }
459                }
460                Ok(text) => {
461                    let chunks = crate::extract::chunk_lines(&text);
462                    if chunks.is_empty() {
463                        (
464                            "no text (scanned?)".to_string(),
465                            crate::extract::metadata_chunks(&name),
466                        )
467                    } else {
468                        ("ok".to_string(), chunks)
469                    }
470                }
471                Err(e) => (
472                    format!("error: {e}"),
473                    crate::extract::metadata_chunks(&name),
474                ),
475            };
476            if let Ok(id) = self
477                .db
478                .upsert_file(&self.active_space.id, &name, &hash, size, &status)
479            {
480                let _ = self.db.set_file_chunks(&id, &chunks);
481                let _ = self.db.set_file_mtime(&id, mtime);
482            }
483        }
484        for gone in known.iter().filter(|f| !seen.contains(&f.name)) {
485            let _ = self.db.delete_file(&gone.id);
486        }
487        self.start_ocr(ocr_jobs);
488        // Backfill vectors for anything whose chunks changed (or that predates
489        // semantic search entirely).
490        self.start_embedding();
491        self.files_cache = self
492            .db
493            .list_files(&self.active_space.id)
494            .unwrap_or_default();
495    }
496
497    /// OCR queued scanned PDFs sequentially off the UI thread. One batch at a
498    /// time: jobs arriving while a batch runs stay at "ocr…" and re-queue on a
499    /// later rescan.
500    pub fn start_ocr(&mut self, jobs: Vec<(String, String, std::path::PathBuf)>) {
501        if jobs.is_empty() || self.ocr_rx.is_some() {
502            return;
503        }
504        let backend = self.ocr_backend();
505        let files_dir = self.space.files_dir(&self.active_space.name);
506        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
507        self.ocr_rx = Some(rx);
508        if let Some(backend) = backend {
509            tokio::spawn(async move {
510                for (space_id, name, path) in jobs {
511                    let is_image = path
512                        .extension()
513                        .and_then(|e| e.to_str())
514                        .is_some_and(crate::extract::is_image_ext);
515                    let result = if is_image {
516                        ocr_image_vlm(&backend, &path, &tx, &space_id, &name).await
517                    } else {
518                        ocr_pdf_vlm(&backend, &path, &tx, &space_id, &name, &files_dir).await
519                    };
520                    if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
521                        return;
522                    }
523                }
524            });
525            return;
526        }
527        tokio::task::spawn_blocking(move || {
528            for (space_id, name, path) in jobs {
529                let is_image = path
530                    .extension()
531                    .and_then(|e| e.to_str())
532                    .is_some_and(crate::extract::is_image_ext);
533                if is_image {
534                    // Images can't be OCR'd via tesseract — skip, it'll re-queue
535                    // on next rescan if a VLM backend is configured.
536                    let _ = tx.send((
537                        space_id,
538                        name,
539                        OcrUpdate::Done(Err("no vlm backend for image ocr".to_string())),
540                    ));
541                    continue;
542                }
543                let progress_tx = tx.clone();
544                let (sid, fname) = (space_id.clone(), name.clone());
545                let progress = move |done: usize, total: usize| {
546                    let _ = progress_tx.send((
547                        sid.clone(),
548                        fname.clone(),
549                        OcrUpdate::Progress(done, total, 0),
550                    ));
551                };
552                let result = match crate::extract::ocr_pdf(&path, &progress) {
553                    Ok(text) => Ok((text, Vec::new())),
554                    Err(crate::extract::OcrError::MissingTools) => {
555                        Err("scanned pdf — install tesseract + poppler for ocr".to_string())
556                    }
557                    Err(crate::extract::OcrError::Failed(e)) => Err(format!("error: ocr: {e}")),
558                };
559                if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
560                    return;
561                }
562            }
563        });
564    }
565
566    /// The vision backend scanned PDFs OCR through, or None for tesseract:
567    /// "local" → Ollama; "vlm"/"auto" with an OCR model + provider → `OpenRouter`.
568    pub fn ocr_backend(&self) -> Option<OcrBackend> {
569        if self.ocr_engine == "local" {
570            let model = self.local_ocr_model.trim();
571            let model = if model.is_empty() { "glm-ocr" } else { model };
572            return Some(OcrBackend::Ollama(
573                reqwest::Client::new(),
574                model.to_string(),
575            ));
576        }
577        if self.vlm_ocr_enabled() {
578            let model = self.ocr_model.trim().to_string();
579            return self
580                .resolve_model_backend(&model)
581                .map(|(p, raw_model)| OcrBackend::Router(p, raw_model));
582        }
583        None
584    }
585
586    /// Cycling the OCR engine to "local" (in /config) pulls a local OCR model
587    /// through Ollama in the background and switches the engine to it when
588    /// the pull succeeds. Defaults to glm-ocr (0.9B — the current open OCR
589    /// benchmark leader).
590    pub fn ocr_local_install(&mut self, arg: &str) {
591        if self.ocr_pull_rx.is_some() {
592            self.push_status("an OCR model pull is already running".to_string());
593            return;
594        }
595        let model = if arg.is_empty() {
596            "glm-ocr".to_string()
597        } else {
598            arg.to_string()
599        };
600        self.local_ocr_model.clone_from(&model);
601        let _ = self.db.set_setting("local_ocr_model", &model);
602        // Under `cargo test` there's no reactor to spawn onto and no real
603        // ollama to pull from — just take the switch synchronously so the
604        // settings-cycle test doesn't need a Tokio runtime.
605        #[cfg(test)]
606        {
607            self.ocr_engine = "local".to_string();
608            let _ = self.db.set_setting("ocr_engine", "local");
609            self.push_status(format!("(test) local OCR: {model}"));
610        }
611        #[cfg(not(test))]
612        {
613            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
614            self.ocr_pull_rx = Some(rx);
615            self.push_status(format!(
616                "pulling {model} via ollama… (keeps running in background)"
617            ));
618            tokio::spawn(async move {
619                let result = match tokio::process::Command::new("ollama")
620                .args(["pull", &model])
621                .output()
622                .await
623            {
624                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
625                    Err("ollama not installed — get it from https://ollama.com (pacman -S ollama), then retry".to_string())
626                }
627                Err(e) => Err(format!("ollama pull failed: {e}")),
628                Ok(out) if !out.status.success() => {
629                    let err = String::from_utf8_lossy(&out.stderr);
630                    let hint = if err.contains("could not connect") || err.contains("connection refused") {
631                        " — is the ollama server running? (systemctl start ollama, or `ollama serve`)"
632                    } else {
633                        ""
634                    };
635                    Err(format!("ollama pull failed: {}{hint}", err.trim()))
636                }
637                Ok(_) => Ok(model),
638            };
639                let _ = tx.send(result);
640            });
641        }
642    }
643
644    /// The local-OCR-model pull finished: point the OCR engine at the local model.
645    pub fn on_ocr_pull(&mut self, r: Option<Result<String, String>>) {
646        let Some(result) = r else {
647            self.ocr_pull_rx = None;
648            return;
649        };
650        self.ocr_pull_rx = None;
651        match result {
652            Ok(model) => {
653                self.ocr_engine = "local".to_string();
654                let _ = self.db.set_setting("ocr_engine", "local");
655                self.push_status(format!(
656                    "local OCR ready: {model} via ollama — Ctrl+O a file in /files to re-run it"
657                ));
658            }
659            Err(e) => self.push_status(e),
660        }
661    }
662
663    /// The `reextract`/`reocr`/delete popup flows live in the view layer;
664    /// this is the re-extract half: zero the selected file's chunks and
665    /// hash/size so the next rescan re-indexes from disk.
666    pub fn reextract_file(&mut self, name: &str) {
667        let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
668            return;
669        };
670        let _ = self.db.set_file_chunks(&f.id, &[]);
671        // Zeroing hash + size guarantees the rescan takes the re-extract path
672        // (a real file is never 0 bytes with an empty hash).
673        let _ = self
674            .db
675            .upsert_file(&self.active_space.id, &f.name, "", 0, "re-extracting");
676        self.push_status(format!("re-extracting: {}", f.name));
677        self.rescan_files();
678    }
679
680    /// The `reocr` popup flow lives in the view layer; this is the OCR half:
681    /// force an OCR pass on one file, bypassing text extraction entirely.
682    /// Useful when `pdf_extract` gives unreliable text and you want VLM OCR
683    /// output instead.
684    pub fn reocr_file(&mut self, name: &str) {
685        let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
686            return;
687        };
688        let ext = std::path::Path::new(&f.name)
689            .extension()
690            .and_then(|e| e.to_str())
691            .unwrap_or("")
692            .to_lowercase();
693        if ext != "pdf" && !crate::extract::is_image_ext(&ext) {
694            self.push_status(format!("only PDFs and images support OCR: {}", f.name));
695            return;
696        }
697        let path = self.space.files_dir(&self.active_space.name).join(&f.name);
698        // Force-cancel any in-progress OCR batch so our job isn't silently dropped
699        self.ocr_rx = None;
700        let _ = self.db.set_file_status(&f.id, "ocr…");
701        self.start_ocr(vec![(self.active_space.id.clone(), f.name.clone(), path)]);
702        self.files_cache = self
703            .db
704            .list_files(&self.active_space.id)
705            .unwrap_or_default();
706        self.push_status(format!("ocr queued: {}", f.name));
707    }
708
709    /// Embed the next imported file whose chunks lack vectors, one file per
710    /// job (the done-handler chains the next). Files with no extractable text
711    /// receive a small filename metadata chunk so they are still searchable.
712    /// No-op without a provider, without an embedding model, or while a job is
713    /// already in flight.
714    pub fn start_embedding(&mut self) {
715        if self.embed_rx.is_some() {
716            return;
717        }
718        let model = self.embedding_model.trim().to_string();
719        if model.is_empty() {
720            return;
721        }
722        let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
723            return;
724        };
725        let space_id = self.active_space.id.clone();
726        let Ok(missing) = self.db.files_missing_embeddings(&space_id) else {
727            return;
728        };
729        let Some(file) = self.db.list_files(&space_id).ok().and_then(|files| {
730            files.into_iter().find(|file| {
731                missing.iter().any(|id| id == &file.id) && !file.status.starts_with("ocr")
732            })
733        }) else {
734            return;
735        };
736        // OCR owns files in this state.  They remain in the database work
737        // queue, but must not receive a placeholder chunk while OCR is still
738        // running.  The ready-file filter above prevents an OCR row from
739        // blocking all other files.
740        let file_id = file.id.clone();
741        let Ok(mut chunks) = self.db.file_chunk_texts(&file_id) else {
742            return;
743        };
744        if chunks.is_empty() {
745            if self
746                .db
747                .set_file_chunks(&file_id, &crate::extract::metadata_chunks(&file.name))
748                .is_err()
749            {
750                return;
751            }
752            chunks = match self.db.file_chunk_texts(&file_id) {
753                Ok(chunks) => chunks,
754                Err(_) => return,
755            };
756        }
757        // Embedding is best-effort background work; outside a runtime (sync
758        // unit tests) there's nowhere to run it, so just skip.
759        let Ok(handle) = tokio::runtime::Handle::try_current() else {
760            return;
761        };
762        // Keep extraction/OCR status text for metadata-only and failed files;
763        // the transient embedding marker is only safe to replace with "ok"
764        // when the original index status was already "ok".
765        let show_embedding_status = matches!(file.status.as_str(), "ok" | "embedding…");
766        if show_embedding_status {
767            let _ = self.db.set_file_status(&file_id, "embedding…");
768        }
769        if space_id == self.active_space.id {
770            self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
771        }
772        let file_name = file.name;
773        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
774        self.embed_rx = Some(rx);
775        handle.spawn(async move {
776            let mut out: Vec<(i64, Vec<f32>)> = Vec::with_capacity(chunks.len());
777            let mut err = None;
778            for batch in chunks.chunks(64) {
779                let inputs: Vec<String> = batch
780                    .iter()
781                    .map(|(_, text)| format!("file: {file_name}\n{text}"))
782                    .collect();
783                match provider.embed(&raw_model, inputs).await {
784                    Ok(vecs) if vecs.len() == batch.len() => out.extend(
785                        batch
786                            .iter()
787                            .zip(vecs)
788                            .map(|((seq, _), vector)| (*seq, vector)),
789                    ),
790                    Ok(vecs) => {
791                        err = Some(format!(
792                            "embedding returned {} vectors for {} chunks",
793                            vecs.len(),
794                            batch.len()
795                        ));
796                        break;
797                    }
798                    Err(e) => {
799                        err = Some(e.to_string());
800                        break;
801                    }
802                }
803            }
804            let result = match err {
805                Some(e) => Err(e),
806                None => Ok(out),
807            };
808            let _ = tx.send((space_id, file_id, result));
809        });
810    }
811
812    /// One embedding job finished: store vectors and chain the next file, or
813    /// surface the error and stop (a dead endpoint shouldn't be hammered —
814    /// the next rescan retries). Search falls back to keywords while vectors
815    /// are missing.
816    pub fn on_embed_done(&mut self, r: Option<crate::app::EmbedMsg>) {
817        let Some((space_id, file_id, result)) = r else {
818            self.embed_rx = None;
819            return;
820        };
821        self.embed_rx = None;
822        let restore_status = self
823            .db
824            .list_files(&space_id)
825            .ok()
826            .and_then(|files| files.into_iter().find(|file| file.id == file_id))
827            .is_some_and(|file| file.status == "embedding…");
828        match result {
829            Ok(vecs) => {
830                let _ = self.db.set_chunk_embeddings(&file_id, &vecs);
831                if restore_status {
832                    let _ = self.db.set_file_status(&file_id, "ok");
833                }
834                self.start_embedding();
835            }
836            Err(e) => {
837                if restore_status {
838                    let _ = self.db.set_file_status(&file_id, "ok");
839                }
840                self.push_status(format!("embedding failed: {e}"));
841            }
842        }
843        if space_id == self.active_space.id {
844            self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
845        }
846    }
847
848    /// A finished OCR job: persist chunks/status, refresh the cache only if the
849    /// file's space is still active. `None` = batch done (channel closed).
850    #[allow(clippy::too_many_lines)]
851    pub fn on_ocr_done(&mut self, r: Option<(String, String, OcrUpdate)>) {
852        let Some((space_id, name, update)) = r else {
853            self.ocr_rx = None;
854            // PDFs imported mid-batch sat at "ocr…" unqueued; this rescan
855            // chains them into a fresh batch instead of stalling until the
856            // user reopens /files.
857            self.rescan_files();
858            return;
859        };
860        let Ok(files) = self.db.list_files(&space_id) else {
861            return;
862        };
863        let Some(f) = files.iter().find(|f| f.name == name) else {
864            return; // deleted mid-OCR
865        };
866        if !f.status.starts_with("ocr") {
867            return; // re-imported mid-OCR — this result is for stale content
868        }
869        let completed = matches!(update, OcrUpdate::Done(_));
870        match update {
871            OcrUpdate::Stage(s) => {
872                // Keep the "ocr" prefix — the stale-check above depends on it.
873                let _ = self.db.set_file_status(&f.id, &format!("ocr: {s}"));
874                if space_id == self.active_space.id {
875                    self.push_status(format!("ocr {name}: {s}"));
876                }
877            }
878            OcrUpdate::Progress(done, total, failed) => {
879                let tail = if failed > 0 {
880                    format!(" ({failed} failed)")
881                } else {
882                    String::new()
883                };
884                let _ = self
885                    .db
886                    .set_file_status(&f.id, &format!("ocr {done}/{total}{tail}"));
887                if space_id == self.active_space.id {
888                    self.push_status(format!("ocr {name}: {done}/{total} pages{tail}"));
889                }
890            }
891            OcrUpdate::Done(Ok((text, errors))) if text.trim().is_empty() => {
892                // Nothing usable came back; keep a filename metadata chunk so
893                // even an image/scanned document remains searchable.
894                let _ = self
895                    .db
896                    .set_file_chunks(&f.id, &crate::extract::metadata_chunks(&name));
897                // Nothing usable came back; say exactly why if we know.
898                let status = match errors.first() {
899                    Some((i, e)) => {
900                        format!("all pages failed (p{}: {})", i + 1, clip_err(e))
901                    }
902                    None => "no text (ocr found nothing)".to_string(),
903                };
904                let _ = self.db.set_file_status(&f.id, &status);
905                if space_id == self.active_space.id {
906                    self.push_status(format!("ocr {name}: {status}"));
907                }
908            }
909            OcrUpdate::Done(Ok((text, errors))) => {
910                let chunks = crate::extract::chunk_lines(&text);
911                let chunks = if chunks.is_empty() {
912                    crate::extract::metadata_chunks(&name)
913                } else {
914                    chunks
915                };
916                let _ = self.db.set_file_chunks(&f.id, &chunks);
917                let status = match errors.first() {
918                    None => "ok".to_string(),
919                    Some((i, e)) => format!(
920                        "ok — {} page{} failed (p{}: {})",
921                        errors.len(),
922                        if errors.len() == 1 { "" } else { "s" },
923                        i + 1,
924                        clip_err(e),
925                    ),
926                };
927                let _ = self.db.set_file_status(&f.id, &status);
928
929                // Rename pasted images (uuid.ext) to uuid-<slug>.ext for @-completion.
930                if let Some(new_name) = Self::descriptive_paste_name(f, &text) {
931                    let dir = self.space.files_dir(&self.active_space.name);
932                    let old_path = dir.join(&f.name);
933                    let new_path = dir.join(&new_name);
934                    if old_path.exists() && std::fs::rename(&old_path, &new_path).is_ok() {
935                        let _ = self.db.rename_file(&f.id, &new_name);
936                        let _ = self
937                            .db
938                            .replace_file_ref_in_messages(&space_id, &f.name, &new_name);
939                        if space_id == self.active_space.id {
940                            self.push_status(format!("ocr done: {new_name}"));
941                        }
942                        // f.name needs the updated name for the message below.
943                    } else if space_id == self.active_space.id {
944                        self.push_status(format!("ocr done: {name}"));
945                    }
946                } else if space_id == self.active_space.id {
947                    self.push_status(format!("ocr done: {name}"));
948                }
949            }
950            OcrUpdate::Done(Err(msg)) => {
951                // A failed first OCR pass has no chunks to embed. Preserve old
952                // extracted text when re-OCRing an already indexed file, but
953                // create metadata for a file that has never been indexed.
954                if self
955                    .db
956                    .file_chunk_texts(&f.id)
957                    .unwrap_or_default()
958                    .is_empty()
959                {
960                    let _ = self
961                        .db
962                        .set_file_chunks(&f.id, &crate::extract::metadata_chunks(&name));
963                }
964                let _ = self.db.set_file_status(&f.id, &msg);
965                if space_id == self.active_space.id {
966                    self.push_status(format!("ocr {name}: {msg}"));
967                }
968            }
969        }
970        if completed && space_id == self.active_space.id {
971            self.start_embedding();
972        }
973        if space_id == self.active_space.id {
974            self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
975        }
976    }
977
978    /// Copy `path` into the active space's files dir and index it. Returns
979    /// the imported file's name. An existing file with the same name is
980    /// overwritten (the rescan re-extracts it).
981    pub fn import_file(&mut self, path: &Path) -> Result<String> {
982        let name = path
983            .file_name()
984            .map(|n| n.to_string_lossy().to_string())
985            .filter(|n| !n.is_empty())
986            .context("path has no file name")?;
987        let dir = self.space.files_dir(&self.active_space.name);
988        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
989        std::fs::copy(path, dir.join(&name))
990            .with_context(|| format!("copying {} into the space", path.display()))?;
991        self.rescan_files();
992        Ok(name)
993    }
994
995    /// Domain half of the files popup's delete: remove the disk copy and
996    /// index rows, refresh the cache. The view owns the mode/selection state.
997    pub fn delete_file(&mut self, name: &str) -> Result<()> {
998        let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
999            return Ok(());
1000        };
1001        let disk = self.space.files_dir(&self.active_space.name).join(&f.name);
1002        if disk.exists() {
1003            std::fs::remove_file(&disk).with_context(|| format!("removing {}", disk.display()))?;
1004        }
1005        self.db.delete_file(&f.id)?;
1006        self.push_status(format!("removed {}", f.name));
1007        self.rescan_files();
1008        Ok(())
1009    }
1010
1011    /// Domain half of the files popup's rename: move the file on disk; the
1012    /// rescan swaps the index rows (old name dropped, new name re-extracted).
1013    /// Returns an error message string when the target already exists or the
1014    /// name is invalid; the view turns it into a status line.
1015    pub fn rename_file(&mut self, name: &str, new: &str) -> Result<()> {
1016        if new.is_empty() || new == name {
1017            return Ok(());
1018        }
1019        if new.contains(['/', '\\']) || new == "." || new == ".." {
1020            anyhow::bail!("invalid name: {new}");
1021        }
1022        let dir = self.space.files_dir(&self.active_space.name);
1023        if dir.join(new).exists() {
1024            anyhow::bail!("{new} already exists");
1025        }
1026        std::fs::rename(dir.join(name), dir.join(new))
1027            .with_context(|| format!("renaming {name} to {new}"))?;
1028        self.rescan_files();
1029        self.push_status(format!("renamed {name} to {new}"));
1030        Ok(())
1031    }
1032
1033    /// If `f` is a pasted image (UUID.ext), generate a descriptive name
1034    /// `uuid-<slug>.ext` from OCR text. Returns None for non-pasted files.
1035    fn descriptive_paste_name(f: &FileRow, ocr_text: &str) -> Option<String> {
1036        let stem = std::path::Path::new(&f.name).file_stem()?.to_str()?;
1037        let ext = std::path::Path::new(&f.name).extension()?.to_str()?;
1038        // Only rename files whose stem is a UUID (pasted images).
1039        if !is_uuid_like(stem) {
1040            return None;
1041        }
1042        let slug = Self::slug_from_ocr(ocr_text)?;
1043        Some(format!("{stem}-{slug}.{ext}"))
1044    }
1045
1046    /// Generate a `snake_case` name from OCR text. Takes first N meaningful words
1047    /// and slugifies them. Returns None if text is empty or has no words.
1048    fn slug_from_ocr(text: &str) -> Option<String> {
1049        let words: Vec<&str> = text
1050            .split_whitespace()
1051            .filter(|w| {
1052                let w = w.trim_matches(|c: char| !c.is_alphanumeric());
1053                w.len() > 2 && w.chars().any(char::is_alphanumeric)
1054            })
1055            .collect();
1056        if words.is_empty() {
1057            return None;
1058        }
1059        let slug: String = words
1060            .iter()
1061            .take(5)
1062            .map(|w| {
1063                w.trim_matches(|c: char| !c.is_alphanumeric())
1064                    .to_lowercase()
1065            })
1066            .collect::<Vec<_>>()
1067            .join("_");
1068        if slug.is_empty() { None } else { Some(slug) }
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use crate::db::Db;
1076    use crate::space::Space;
1077
1078    fn test_app() -> App {
1079        let db = Db::open_in_memory().unwrap();
1080        let root = std::env::temp_dir().join(format!("nexus-files-test-{}", uuid::Uuid::new_v4()));
1081        std::fs::create_dir_all(root.join("spaces")).unwrap();
1082        let space = Space { root };
1083        App::new(db, Some("k"), space)
1084    }
1085
1086    #[tokio::test]
1087    async fn embedder_queue_backfills_chains_and_stops_on_error() {
1088        let mut a = test_app();
1089        let space = a.active_space.id.clone();
1090        let id = a.db.upsert_file(&space, "b.txt", "h", 1, "ok").unwrap();
1091        a.db.set_file_chunks(&id, &[("l".into(), "text".into())])
1092            .unwrap();
1093
1094        // No provider → no-op.
1095        let saved = a.backends.clone();
1096        a.backends = crate::app::Backends::default();
1097        a.start_embedding();
1098        assert!(a.embed_rx.is_none());
1099        a.backends = saved;
1100
1101        // Blank embedding model → no-op.
1102        let m = std::mem::take(&mut a.embedding_model);
1103        a.start_embedding();
1104        assert!(a.embed_rx.is_none());
1105        a.embedding_model = m;
1106
1107        // Missing vectors + provider → queued, status flips.
1108        a.start_embedding();
1109        assert!(a.embed_rx.is_some());
1110        let files = a.db.list_files(&space).unwrap();
1111        assert!(
1112            files[0].status.starts_with("embedding"),
1113            "{}",
1114            files[0].status
1115        );
1116
1117        // Success: vectors stored, status ok, file leaves the missing list.
1118        a.on_embed_done(Some((
1119            space.clone(),
1120            id.clone(),
1121            Ok(vec![(0, vec![1.0f32, 0.0])]),
1122        )));
1123        assert!(a.db.files_missing_embeddings(&space).unwrap().is_empty());
1124        assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1125
1126        // Error: status restored, no re-queue (don't hammer a dead endpoint).
1127        a.db.set_file_chunks(&id, &[("l".into(), "new".into())])
1128            .unwrap();
1129        a.on_embed_done(Some((space.clone(), id.clone(), Err("offline".into()))));
1130        assert!(a.embed_rx.is_none());
1131        let (_, status) = a.drain_ui_events();
1132        assert!(status.contains("embedding failed"));
1133        assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1134    }
1135
1136    #[test]
1137    fn import_copies_extracts_and_indexes() {
1138        let mut a = test_app();
1139        let src = std::env::temp_dir().join(format!("nexus-src-{}.md", uuid::Uuid::new_v4()));
1140        std::fs::write(&src, "# quarterly report\nrevenue up").unwrap();
1141
1142        let name = a.import_file(&src).unwrap();
1143        assert_eq!(name, src.file_name().unwrap().to_string_lossy());
1144        assert_eq!(a.files_cache.len(), 1);
1145        assert_eq!(a.files_cache[0].status, "ok");
1146        // Copied into the space's files dir.
1147        assert!(a.space.files_dir(&a.active_space.name).join(&name).exists());
1148        // Indexed: searchable.
1149        let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1150            .unwrap();
1151        assert_eq!(hits.len(), 1);
1152    }
1153
1154    #[test]
1155    fn generic_code_and_binary_files_get_index_chunks() {
1156        let mut a = test_app();
1157        let dir = a.space.files_dir(&a.active_space.name);
1158        std::fs::create_dir_all(&dir).unwrap();
1159        std::fs::write(dir.join("page.html"), "<main>searchable markup</main>").unwrap();
1160        std::fs::write(dir.join("job.py"), "def searchable_code():\n    return 42").unwrap();
1161        // A binary file cannot provide content to a text embedding model, but
1162        // its metadata still makes the upload searchable by name.
1163        std::fs::write(dir.join("asset.bin"), [0u8, 1, 2, 3]).unwrap();
1164
1165        a.rescan_files();
1166        for name in ["page.html", "job.py", "asset.bin"] {
1167            let file = a.files_cache.iter().find(|file| file.name == name).unwrap();
1168            assert!(a.db.file_has_chunks(&file.id).unwrap(), "{name}");
1169        }
1170        let hits =
1171            crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "searchable", 8)
1172                .unwrap();
1173        assert_eq!(hits.len(), 2, "code files should be FTS indexed");
1174        let binary_hits =
1175            crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "asset.bin", 8)
1176                .unwrap();
1177        assert_eq!(
1178            binary_hits.len(),
1179            1,
1180            "binary upload should be name-searchable"
1181        );
1182    }
1183
1184    #[test]
1185    fn cold_cache_after_restore_reindexes_from_disk() {
1186        // A real file db: the cold-cache scenario only exists when the
1187        // sibling cache.db can be deleted underneath the durable db (a
1188        // restore does exactly that — backup excludes cache.db).
1189        let root = std::env::temp_dir().join(format!("nexus-cold-{}", uuid::Uuid::new_v4()));
1190        std::fs::create_dir_all(root.join("spaces")).unwrap();
1191        let space = Space { root };
1192        let db = Db::open(&space.db_path()).unwrap();
1193        let mut a = App::new(db, Some("k"), space);
1194
1195        let src = std::env::temp_dir().join(format!("nexus-cold-src-{}.md", uuid::Uuid::new_v4()));
1196        std::fs::write(&src, "# report\nresilience is a property").unwrap();
1197        let name = a.import_file(&src).unwrap();
1198        let id = a.files_cache[0].id.clone();
1199        assert!(a.db.file_indexed(&id).unwrap());
1200        assert!(
1201            crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1202                .unwrap()
1203                .is_some()
1204        );
1205        // A restore runs with the app closed — drop the connections so the
1206        // cache file can actually go away.
1207        let root = a.space.root.clone();
1208        drop(a);
1209
1210        // Restore: durable db survives, cache.db is dropped.
1211        let cache_path = root.join("cache.db");
1212        assert!(cache_path.exists());
1213        std::fs::remove_file(&cache_path).unwrap();
1214
1215        let mut a = App::new(
1216            Db::open(&root.join("nexus.db")).unwrap(),
1217            Some("k"),
1218            Space { root },
1219        );
1220        assert!(!a.db.file_indexed(&id).unwrap());
1221
1222        // The rescan must not trust the stat skip on an unchanged file — it
1223        // re-extracts and rewrites the index state.
1224        a.rescan_files();
1225        assert!(a.db.file_indexed(&id).unwrap());
1226        assert_eq!(
1227            crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1228                .unwrap()
1229                .as_deref(),
1230            Some("# report\nresilience is a property")
1231        );
1232        assert_eq!(a.db.list_files(&a.active_space.id).unwrap()[0].status, "ok");
1233    }
1234
1235    #[test]
1236    fn ollama_ocr_body_uses_native_generate_shape() {
1237        let body = ollama_ocr_body("glm-ocr", "QUFB");
1238        assert_eq!(body["model"], "glm-ocr");
1239        assert_eq!(body["stream"], false);
1240        assert_eq!(body["images"][0], "QUFB"); // raw base64, not a data URL
1241        assert!(body["prompt"].as_str().unwrap().contains("furigana"));
1242        assert!(
1243            body.get("messages").is_none(),
1244            "must not be OpenAI chat shape"
1245        );
1246    }
1247
1248    #[test]
1249    fn ocr_backend_routes_by_engine() {
1250        let mut a = test_app();
1251        // auto + model + provider → OpenRouter.
1252        assert!(matches!(a.ocr_backend(), Some(OcrBackend::Router(..))));
1253        // local → Ollama regardless of provider/ocr_model.
1254        a.ocr_engine = "local".to_string();
1255        a.local_ocr_model = String::new(); // blank falls back to glm-ocr
1256        match a.ocr_backend() {
1257            Some(OcrBackend::Ollama(_, model)) => assert_eq!(model, "glm-ocr"),
1258            other => panic!("expected ollama backend, got {}", other.is_some()),
1259        }
1260        // tesseract → none.
1261        a.ocr_engine = "tesseract".to_string();
1262        assert!(a.ocr_backend().is_none());
1263        // auto without provider → none (tesseract fallback).
1264        a.ocr_engine = "auto".to_string();
1265        a.backends = crate::app::Backends::default();
1266        assert!(a.ocr_backend().is_none());
1267    }
1268
1269    #[tokio::test]
1270    async fn ocr_pull_success_switches_engine_to_local() {
1271        let mut a = test_app();
1272        a.on_ocr_pull(Some(Ok("glm-ocr".to_string())));
1273        assert_eq!(a.ocr_engine, "local");
1274        let (_, status) = a.drain_ui_events();
1275        assert!(status.contains("local OCR ready"));
1276        a.on_ocr_pull(Some(Err("ollama not installed — get it".to_string())));
1277        assert_eq!(a.ocr_engine, "local"); // engine untouched on failure
1278        let (_, status) = a.drain_ui_events();
1279        assert!(status.contains("ollama not installed"));
1280    }
1281
1282    #[test]
1283    fn ocr_statuses_surface_stages_failures_and_reasons() {
1284        let mut a = test_app();
1285        let space = a.active_space.id.clone();
1286        let id =
1287            a.db.upsert_file(&space, "scan.pdf", "h", 1, "ocr…")
1288                .unwrap();
1289
1290        // Stage → visible phase, still "ocr"-prefixed (stale-check depends on it).
1291        a.on_ocr_done(Some((
1292            space.clone(),
1293            "scan.pdf".into(),
1294            OcrUpdate::Stage("rendering pages (300 dpi)…".into()),
1295        )));
1296        let status = a.db.list_files(&space).unwrap()[0].status.clone();
1297        assert_eq!(status, "ocr: rendering pages (300 dpi)…");
1298
1299        // Progress with failures shows the count.
1300        a.on_ocr_done(Some((
1301            space.clone(),
1302            "scan.pdf".into(),
1303            OcrUpdate::Progress(5, 10, 2),
1304        )));
1305        assert_eq!(
1306            a.db.list_files(&space).unwrap()[0].status,
1307            "ocr 5/10 (2 failed)"
1308        );
1309        assert!(a.last_status().contains("5/10 pages (2 failed)"));
1310
1311        // Partial success keeps the first failure's reason in the status.
1312        a.on_ocr_done(Some((
1313            space.clone(),
1314            "scan.pdf".into(),
1315            OcrUpdate::Done(Ok((
1316                "[page 1]\ntext".to_string(),
1317                vec![
1318                    (2, "timeout after 600s".to_string()),
1319                    (4, "boom".to_string()),
1320                ],
1321            ))),
1322        )));
1323        let status = a.db.list_files(&space).unwrap()[0].status.clone();
1324        assert_eq!(status, "ok — 2 pages failed (p3: timeout after 600s)");
1325
1326        // All pages failed → the reason, not a bland "no text".
1327        let _ = a.db.set_file_status(&id, "ocr…");
1328        a.on_ocr_done(Some((
1329            space.clone(),
1330            "scan.pdf".into(),
1331            OcrUpdate::Done(Ok((
1332                String::new(),
1333                vec![(0, "cannot reach ollama at 127.0.0.1:11434 — is it running? (systemctl start ollama)".to_string())],
1334            ))),
1335        )));
1336        let status = a.db.list_files(&space).unwrap()[0].status.clone();
1337        assert!(
1338            status.starts_with("all pages failed (p1: cannot reach ollama"),
1339            "{status}"
1340        );
1341        // Must NOT start with "ocr" — that prefix means "queued" to the rescan.
1342        assert!(!status.starts_with("ocr"), "{status}");
1343    }
1344
1345    #[test]
1346    fn reextract_clears_stale_chunks_and_reindexes_from_disk() {
1347        let mut a = test_app();
1348        let dir = a.space.files_dir(&a.active_space.name);
1349        std::fs::create_dir_all(&dir).unwrap();
1350        std::fs::write(dir.join("doc.txt"), "real content on disk").unwrap();
1351        a.rescan_files();
1352        let id = a.files_cache[0].id.clone();
1353
1354        // Simulate a bad old extraction (e.g. tesseract-mangled OCR).
1355        a.db.set_file_chunks(&id, &[("p1".into(), "garbage".into())])
1356            .unwrap();
1357
1358        a.reextract_file("doc.txt");
1359        assert!(
1360            a.last_status().contains("re-extracting"),
1361            "{}",
1362            a.last_status()
1363        );
1364        let texts = a.db.file_chunk_texts(&id).unwrap();
1365        assert_eq!(texts.len(), 1);
1366        assert!(texts[0].1.contains("real content"), "{texts:?}");
1367        assert_eq!(a.files_cache[0].status, "ok");
1368    }
1369
1370    #[test]
1371    fn rescan_picks_up_dropped_and_deleted_files() {
1372        let mut a = test_app();
1373        let dir = a.space.files_dir(&a.active_space.name);
1374        std::fs::create_dir_all(&dir).unwrap();
1375        std::fs::write(dir.join("dropped.txt"), "hello dropped").unwrap();
1376
1377        a.rescan_files();
1378        assert_eq!(a.files_cache.len(), 1);
1379        assert_eq!(a.files_cache[0].name, "dropped.txt");
1380
1381        // Changing content re-extracts (hash change), deleting drops the row.
1382        std::fs::write(dir.join("dropped.txt"), "hello again").unwrap();
1383        a.rescan_files();
1384        assert_eq!(a.files_cache.len(), 1);
1385        std::fs::remove_file(dir.join("dropped.txt")).unwrap();
1386        a.rescan_files();
1387        assert!(a.files_cache.is_empty());
1388    }
1389
1390    #[test]
1391    fn empty_extraction_gets_no_text_status() {
1392        let mut a = test_app();
1393        let dir = a.space.files_dir(&a.active_space.name);
1394        std::fs::create_dir_all(&dir).unwrap();
1395        std::fs::write(dir.join("empty.txt"), "   ").unwrap();
1396        a.rescan_files();
1397        assert_eq!(a.files_cache[0].status, "no text (scanned?)");
1398    }
1399
1400    #[test]
1401    fn rescan_skips_stat_unchanged_files_without_rehashing() {
1402        let mut a = test_app();
1403        let dir = a.space.files_dir(&a.active_space.name);
1404        std::fs::create_dir_all(&dir).unwrap();
1405        std::fs::write(dir.join("book.txt"), "big content").unwrap();
1406        a.rescan_files();
1407        let f = a.files_cache[0].clone();
1408        assert!(f.mtime > 0, "mtime recorded on index");
1409
1410        // Plant a wrong hash; a stat-unchanged rescan must not correct it —
1411        // proof the file wasn't re-read/re-hashed.
1412        a.db.upsert_file(&a.active_space.id, "book.txt", "sentinel", f.size, "ok")
1413            .unwrap();
1414        a.rescan_files();
1415        assert_eq!(a.files_cache[0].hash, "sentinel");
1416
1417        // A size change busts the stat check and re-hashes for real.
1418        std::fs::write(dir.join("book.txt"), "big content grew").unwrap();
1419        a.rescan_files();
1420        assert_ne!(a.files_cache[0].hash, "sentinel");
1421        assert_eq!(a.files_cache[0].status, "ok");
1422    }
1423
1424    #[test]
1425    fn rename_moves_disk_file_and_reindexes() {
1426        let mut a = test_app();
1427        let dir = a.space.files_dir(&a.active_space.name);
1428        std::fs::create_dir_all(&dir).unwrap();
1429        std::fs::write(dir.join("old.txt"), "searchable content").unwrap();
1430        std::fs::write(dir.join("taken.txt"), "x").unwrap();
1431        a.rescan_files();
1432
1433        // Collides with an existing name: rejected, nothing moves.
1434        assert!(a.rename_file("old.txt", "taken.txt").is_err());
1435        assert!(dir.join("old.txt").exists());
1436
1437        // Bad name rejected.
1438        assert!(a.rename_file("old.txt", "../evil.txt").is_err());
1439
1440        // Valid rename: disk moves, index follows.
1441        a.rename_file("old.txt", "new.txt").unwrap();
1442        assert!(!dir.join("old.txt").exists());
1443        assert!(dir.join("new.txt").exists());
1444        assert!(a.files_cache.iter().any(|f| f.name == "new.txt"));
1445    }
1446
1447    #[test]
1448    fn delete_removes_disk_file_and_row() {
1449        let mut a = test_app();
1450        let dir = a.space.files_dir(&a.active_space.name);
1451        std::fs::create_dir_all(&dir).unwrap();
1452        std::fs::write(dir.join("gone.txt"), "bye").unwrap();
1453        a.rescan_files();
1454        a.delete_file("gone.txt").unwrap();
1455        assert!(a.files_cache.is_empty());
1456        assert!(!dir.join("gone.txt").exists());
1457    }
1458
1459    #[test]
1460    fn import_file_copies_and_indexes_typed_path() {
1461        let mut a = test_app();
1462        let src = std::env::temp_dir().join(format!("nexus-add-{}.txt", uuid::Uuid::new_v4()));
1463        std::fs::write(&src, "typed in").unwrap();
1464        let name = a.import_file(&src).unwrap();
1465        assert_eq!(a.files_cache.len(), 1);
1466        assert_eq!(a.files_cache[0].name, name);
1467
1468        // A bad path is an error, and the cache stays unchanged.
1469        assert!(
1470            a.import_file(std::path::Path::new("/definitely/not/a/file"))
1471                .is_err()
1472        );
1473        assert_eq!(a.files_cache.len(), 1);
1474    }
1475
1476    #[tokio::test]
1477    async fn rescan_marks_empty_pdf_ocr_and_spawns_batch() {
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();
1482
1483        a.rescan_files();
1484        assert_eq!(a.files_cache[0].status, "ocr…");
1485        assert!(a.ocr_rx.is_some(), "an ocr batch should be in flight");
1486
1487        // A second rescan while the batch is in flight does not re-queue.
1488        a.rescan_files();
1489        assert_eq!(a.files_cache[0].status, "ocr…");
1490    }
1491
1492    #[tokio::test]
1493    async fn rescan_requeues_stale_ocr_status_when_idle() {
1494        let mut a = test_app();
1495        let dir = a.space.files_dir(&a.active_space.name);
1496        std::fs::create_dir_all(&dir).unwrap();
1497        std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1498        a.rescan_files();
1499
1500        // Simulate an app restart mid-OCR: status stuck at "ocr…", no batch in flight.
1501        a.ocr_rx = None;
1502        a.rescan_files();
1503        assert!(a.ocr_rx.is_some(), "stale ocr… should re-queue");
1504    }
1505
1506    #[test]
1507    fn on_ocr_done_ok_indexes_and_marks_ok() {
1508        let mut a = test_app();
1509        let id =
1510            a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1511                .unwrap();
1512        let _ = id;
1513        a.on_ocr_done(Some((
1514            a.active_space.id.clone(),
1515            "scan.pdf".to_string(),
1516            OcrUpdate::Done(Ok((
1517                "[page 1]\nquarterly revenue table".to_string(),
1518                Vec::new(),
1519            ))),
1520        )));
1521        assert_eq!(a.files_cache[0].status, "ok");
1522        let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1523            .unwrap();
1524        assert_eq!(hits.len(), 1);
1525    }
1526
1527    #[test]
1528    fn on_ocr_progress_updates_status_and_status_line() {
1529        let mut a = test_app();
1530        a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1531            .unwrap();
1532        a.on_ocr_done(Some((
1533            a.active_space.id.clone(),
1534            "scan.pdf".to_string(),
1535            OcrUpdate::Progress(3, 10, 0),
1536        )));
1537        assert_eq!(a.files_cache[0].status, "ocr 3/10");
1538        assert!(a.last_status().contains("3/10"), "{}", a.last_status());
1539
1540        // Progress for a file mid-way is still non-terminal: a Done after it applies.
1541        a.on_ocr_done(Some((
1542            a.active_space.id.clone(),
1543            "scan.pdf".to_string(),
1544            OcrUpdate::Done(Ok(("[page 1]\nfound".to_string(), Vec::new()))),
1545        )));
1546        assert_eq!(a.files_cache[0].status, "ok");
1547    }
1548
1549    #[test]
1550    fn on_ocr_done_empty_and_err_statuses() {
1551        let mut a = test_app();
1552        a.db.upsert_file(&a.active_space.id, "blank.pdf", "h1", 9, "ocr…")
1553            .unwrap();
1554        a.db.upsert_file(&a.active_space.id, "bad.pdf", "h2", 9, "ocr…")
1555            .unwrap();
1556
1557        a.on_ocr_done(Some((
1558            a.active_space.id.clone(),
1559            "blank.pdf".to_string(),
1560            OcrUpdate::Done(Ok((String::new(), Vec::new()))),
1561        )));
1562        a.on_ocr_done(Some((
1563            a.active_space.id.clone(),
1564            "bad.pdf".to_string(),
1565            OcrUpdate::Done(Err(
1566                "scanned pdf — install tesseract + poppler for ocr".to_string()
1567            )),
1568        )));
1569
1570        let by_name = |a: &App, n: &str| {
1571            a.files_cache
1572                .iter()
1573                .find(|f| f.name == n)
1574                .unwrap()
1575                .status
1576                .clone()
1577        };
1578        assert_eq!(by_name(&a, "blank.pdf"), "no text (ocr found nothing)");
1579        assert_eq!(
1580            by_name(&a, "bad.pdf"),
1581            "scanned pdf — install tesseract + poppler for ocr"
1582        );
1583    }
1584
1585    #[test]
1586    fn on_ocr_done_for_inactive_space_writes_db_but_not_cache() {
1587        let mut a = test_app();
1588        let other = a.db.create_space("other").unwrap();
1589        a.db.upsert_file(&other.id, "scan.pdf", "h", 9, "ocr…")
1590            .unwrap();
1591
1592        a.on_ocr_done(Some((
1593            other.id.clone(),
1594            "scan.pdf".to_string(),
1595            OcrUpdate::Done(Ok(("found text".to_string(), Vec::new()))),
1596        )));
1597
1598        assert!(
1599            a.files_cache.is_empty(),
1600            "active-space cache must not show other space's file"
1601        );
1602        let rows = a.db.list_files(&other.id).unwrap();
1603        assert_eq!(rows[0].status, "ok");
1604
1605        // Deleted-mid-OCR: result for a row that no longer exists is a no-op.
1606        a.on_ocr_done(Some((
1607            other.id.clone(),
1608            "gone.pdf".to_string(),
1609            OcrUpdate::Done(Ok(("x".to_string(), Vec::new()))),
1610        )));
1611    }
1612
1613    #[tokio::test]
1614    async fn on_ocr_done_none_clears_channel_and_requeues_stragglers() {
1615        let mut a = test_app();
1616        let dir = a.space.files_dir(&a.active_space.name);
1617        std::fs::create_dir_all(&dir).unwrap();
1618        // A scanned PDF stuck at "ocr…" (imported while a batch was running).
1619        std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1620        a.rescan_files();
1621        assert!(a.ocr_rx.is_some());
1622        // Batch finishes: channel clears, and the straggler chains into a new batch.
1623        a.on_ocr_done(None);
1624        assert!(a.ocr_rx.is_some(), "straggler should re-queue on batch end");
1625    }
1626}