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    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            // Unchanged by stat: skip entirely — no read, no hash. This is what
398            // keeps /files and space switches snappy with big filesets.
399            if let Some(f) = existing
400                && f.size == disk_size
401                && f.mtime == mtime
402                && mtime != 0
403            {
404                // Stale "ocr…"/"ocr N/M" (app quit mid-OCR) re-queues once no
405                // batch is in flight.
406                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                // Content unchanged (touched, or indexed before mtimes were
424                // tracked): just record the stat for next time.
425                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            // Cold cache (fresh restore, wiped cache.db): the durable row
432            // survives but this device's index state is gone — fall through
433            // to re-extract so chunks/embeddings rebuild here.
434            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        // Backfill vectors for anything whose chunks changed (or that predates
465        // semantic search entirely).
466        self.start_embedding();
467        self.files_cache = self
468            .db
469            .list_files(&self.active_space.id)
470            .unwrap_or_default();
471    }
472
473    /// OCR queued scanned PDFs sequentially off the UI thread. One batch at a
474    /// time: jobs arriving while a batch runs stay at "ocr…" and re-queue on a
475    /// later rescan.
476    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                    // Images can't be OCR'd via tesseract — skip, it'll re-queue
511                    // on next rescan if a VLM backend is configured.
512                    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    /// The vision backend scanned PDFs OCR through, or None for tesseract:
543    /// "local" → Ollama; "vlm"/"auto" with an OCR model + provider → `OpenRouter`.
544    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    /// Cycling the OCR engine to "local" (in /config) pulls a local OCR model
563    /// through Ollama in the background and switches the engine to it when
564    /// the pull succeeds. Defaults to glm-ocr (0.9B — the current open OCR
565    /// benchmark leader).
566    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        // Under `cargo test` there's no reactor to spawn onto and no real
579        // ollama to pull from — just take the switch synchronously so the
580        // settings-cycle test doesn't need a Tokio runtime.
581        #[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    /// The local-OCR-model pull finished: point the OCR engine at the local model.
621    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    /// The `reextract`/`reocr`/delete popup flows live in the view layer;
640    /// this is the re-extract half: zero the selected file's chunks and
641    /// hash/size so the next rescan re-indexes from disk.
642    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        // Zeroing hash + size guarantees the rescan takes the re-extract path
648        // (a real file is never 0 bytes with an empty hash).
649        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    /// The `reocr` popup flow lives in the view layer; this is the OCR half:
657    /// force an OCR pass on one file, bypassing text extraction entirely.
658    /// Useful when `pdf_extract` gives unreliable text and you want VLM OCR
659    /// output instead.
660    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        // Force-cancel any in-progress OCR batch so our job isn't silently dropped
675        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    /// Embed the next file whose chunks lack vectors, one file per job (the
686    /// done-handler chains the next). No-op without a provider, without an
687    /// embedding model, or while a job is already in flight.
688    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        // Embedding is best-effort background work; outside a runtime (sync
711        // unit tests) there's nowhere to run it, so just skip.
712        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    /// One embedding job finished: store vectors and chain the next file, or
743    /// surface the error and stop (a dead endpoint shouldn't be hammered —
744    /// the next rescan retries). Either way the file's status returns to "ok";
745    /// search falls back to keywords while vectors are missing.
746    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    /// A finished OCR job: persist chunks/status, refresh the cache only if the
769    /// file's space is still active. `None` = batch done (channel closed).
770    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            // PDFs imported mid-batch sat at "ocr…" unqueued; this rescan
774            // chains them into a fresh batch instead of stalling until the
775            // user reopens /files.
776            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; // deleted mid-OCR
784        };
785        if !f.status.starts_with("ocr") {
786            return; // re-imported mid-OCR — this result is for stale content
787        }
788        match update {
789            OcrUpdate::Stage(s) => {
790                // Keep the "ocr" prefix — the stale-check above depends on it.
791                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                // Nothing usable came back; say exactly why if we know.
811                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                // Rename pasted images (uuid.ext) to uuid-<slug>.ext for @-completion.
839                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                        // f.name needs the updated name for the message below.
852                    } 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    /// Copy `path` into the active space's files dir and index it. Returns
872    /// the imported file's name. An existing file with the same name is
873    /// overwritten (the rescan re-extracts it).
874    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    /// Domain half of the files popup's delete: remove the disk copy and
889    /// index rows, refresh the cache. The view owns the mode/selection state.
890    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    /// Domain half of the files popup's rename: move the file on disk; the
905    /// rescan swaps the index rows (old name dropped, new name re-extracted).
906    /// Returns an error message string when the target already exists or the
907    /// name is invalid; the view turns it into a status line.
908    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    /// If `f` is a pasted image (UUID.ext), generate a descriptive name
927    /// `uuid-<slug>.ext` from OCR text. Returns None for non-pasted files.
928    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        // Only rename files whose stem is a UUID (pasted images).
932        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    /// Generate a `snake_case` name from OCR text. Takes first N meaningful words
940    /// and slugifies them. Returns None if text is empty or has no words.
941    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        // No provider → no-op.
988        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        // Blank embedding model → no-op.
995        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        // Missing vectors + provider → queued, status flips.
1001        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        // Success: vectors stored, status ok, file leaves the missing list.
1011        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        // Error: status restored, no re-queue (don't hammer a dead endpoint).
1020        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        // Copied into the space's files dir.
1040        assert!(a.space.files_dir(&a.active_space.name).join(&name).exists());
1041        // Indexed: searchable.
1042        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        // A real file db: the cold-cache scenario only exists when the
1050        // sibling cache.db can be deleted underneath the durable db (a
1051        // restore does exactly that — backup excludes cache.db).
1052        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        // A restore runs with the app closed — drop the connections so the
1069        // cache file can actually go away.
1070        let root = a.space.root.clone();
1071        drop(a);
1072
1073        // Restore: durable db survives, cache.db is dropped.
1074        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        // The rescan must not trust the stat skip on an unchanged file — it
1086        // re-extracts and rewrites the index state.
1087        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"); // raw base64, not a data URL
1104        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        // auto + model + provider → OpenRouter.
1115        assert!(matches!(a.ocr_backend(), Some(OcrBackend::Router(..))));
1116        // local → Ollama regardless of provider/ocr_model.
1117        a.ocr_engine = "local".to_string();
1118        a.local_ocr_model = String::new(); // blank falls back to glm-ocr
1119        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        // tesseract → none.
1124        a.ocr_engine = "tesseract".to_string();
1125        assert!(a.ocr_backend().is_none());
1126        // auto without provider → none (tesseract fallback).
1127        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"); // engine untouched on failure
1141        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        // Stage → visible phase, still "ocr"-prefixed (stale-check depends on it).
1154        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        // Progress with failures shows the count.
1163        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        // Partial success keeps the first failure's reason in the status.
1175        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        // All pages failed → the reason, not a bland "no text".
1190        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        // Must NOT start with "ocr" — that prefix means "queued" to the rescan.
1205        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        // Simulate a bad old extraction (e.g. tesseract-mangled OCR).
1218        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        // Changing content re-extracts (hash change), deleting drops the row.
1245        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        // Plant a wrong hash; a stat-unchanged rescan must not correct it —
1274        // proof the file wasn't re-read/re-hashed.
1275        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        // A size change busts the stat check and re-hashes for real.
1281        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        // Collides with an existing name: rejected, nothing moves.
1297        assert!(a.rename_file("old.txt", "taken.txt").is_err());
1298        assert!(dir.join("old.txt").exists());
1299
1300        // Bad name rejected.
1301        assert!(a.rename_file("old.txt", "../evil.txt").is_err());
1302
1303        // Valid rename: disk moves, index follows.
1304        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        // A bad path is an error, and the cache stays unchanged.
1332        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 second rescan while the batch is in flight does not re-queue.
1351        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        // Simulate an app restart mid-OCR: status stuck at "ocr…", no batch in flight.
1364        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        // Progress for a file mid-way is still non-terminal: a Done after it applies.
1404        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        // Deleted-mid-OCR: result for a row that no longer exists is a no-op.
1469        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        // A scanned PDF stuck at "ocr…" (imported while a batch was running).
1482        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        // Batch finishes: channel clears, and the straggler chains into a new batch.
1486        a.on_ocr_done(None);
1487        assert!(a.ocr_rx.is_some(), "straggler should re-queue on batch end");
1488    }
1489}