Skip to content

feat: wire up on-disk OCR cache#4

Merged
green2grey merged 4 commits into
mainfrom
feat/ocr-cache
Jul 6, 2026
Merged

feat: wire up on-disk OCR cache#4
green2grey merged 4 commits into
mainfrom
feat/ocr-cache

Conversation

@green2grey

Copy link
Copy Markdown
Member

Summary

Wires up the previously-unused cache.rs module as an on-disk OCR cache: results are stored as JSON under ~/.cache/quickview/ocr/, keyed by blake3 of path + lang + mtime + size. Closes out the Phase 7 caching task ("re-opening the same image is faster due to caching").

This revises ADR-0009's "in-memory first" decision (rationale recorded in new implementation notes in the ADR): Quick Preview spawns a fresh process per invocation, so only a persistent cache helps the flagship workflow — and it also covers in-session arrow-key revisits, since re-reading a few-KB JSON is effectively instant.

Design points

  • Staleness is free: mtime+size are part of the key, so an edited file produces a new key and is simply a miss — no invalidation logic.
  • Threading unchanged: the cache check/write lives inside the existing OCR worker-thread closure. The main thread never touches disk, and cache hits flow through the same async-channel as fresh results, so the monotonic job-id guard applies to hits and misses identically.
  • Atomic writes (temp file + rename in the same directory): concurrent QuickView processes are a designed use case (one per Quick Preview invocation), so a torn write must never be readable.
  • Empty results are cached (text-free images don't re-run tesseract); failures are not, so transient errors retry on next open. A failed cache write is a warning, never a failed OCR.
  • No eviction in v1: entries are a few KB; manual cleanup is rm -rf ~/.cache/quickview/ocr. Real eviction arrives with Phase 8's SQLite cache. Tesseract is currently invoked with no psm/oem flags, so lang is the only setting and it is in the key; the ADR notes that future configurable settings must join the key.

Testing

  • 4 new unit tests in cache.rs: key sensitivity (lang/path/size/mtime each change the key), store/load round-trip (incl. no leftover temp files), corrupt entry → miss, absent entry → miss.
  • cargo fmt / clippy --all-targets --all-features -D warnings / cargo test --all clean.
  • Smoke-tested: first open writes the entry; second open logs OCR cache hit with the correct text; touch on the image causes a miss + second entry; --quick-preview in a fresh process gets the hit.

Cache OCR results as JSON under ~/.cache/quickview/ocr/, keyed by
blake3(path + lang + mtime + size) so edited files are simply misses.
Revises ADR-0009's in-memory-first decision: Quick Preview spawns a
fresh process per invocation, so only a persistent cache helps it.

- cache.rs: load_ocr/store_ocr taking an explicit cache root (testable
  without ProjectDirs); atomic temp-file + rename writes since
  concurrent QuickView processes are a designed use case
- start_ocr: cache check/write inside the existing worker-thread
  closure — main thread never touches disk, and hits flow through the
  same channel so the OCR job-id guard applies unchanged
- empty results are cached (text-free images don't re-run tesseract);
  failures are not, so transient errors retry on next open
- no eviction in v1 (entries are a few KB); documented in ADR-0009
  implementation notes along with the future settings-in-key rule
- tests: key sensitivity (lang/path/size/mtime), round-trip, corrupt
  and absent entries as misses
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99ae4273-70ed-47cf-937b-200aea6f36bd

📥 Commits

Reviewing files that changed from the base of the PR and between b4c327d and 4d22e87.

📒 Files selected for processing (2)
  • adrs/ADR-0009-Caching.md
  • crates/quickview-core/src/cache.rs
✅ Files skipped from review due to trivial changes (1)
  • adrs/ADR-0009-Caching.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/quickview-core/src/cache.rs

Summary by CodeRabbit

  • New Features
    • OCR results are now cached on disk using a key based on path + language + file metadata (mtime with nanosecond precision + size), stored under ~/.cache/quickview/ocr/, so repeat lookups are faster across restarts.
    • Empty OCR results are cached to reduce repeated OCR on unchanged files.
  • Bug Fixes
    • Missing or corrupt cache entries safely fall back to rerunning OCR; transient OCR-related issues can recover next time.
    • Cache writes use atomic updates to prevent partial data during concurrent use.
    • Input paths are now canonicalized when possible (for existing files), improving consistency for stdin and relative paths.

Walkthrough

Implements an on-disk OCR cache keyed by path, language, mtime, and size. The cache module now resolves entry paths from an explicit root and can load/store JSON cache files atomically. The OCR worker consults the cache before Tesseract, and docs were updated.

Changes

On-disk OCR cache

Layer / File(s) Summary
Cache path, load, and store API
crates/quickview-core/src/cache.rs
ocr_cache_path now takes an explicit cache_root and returns PathBuf directly; new load_ocr and store_ocr functions read/write JSON entries with atomic temp-file + rename writes.
Cache tests and crate dependencies
crates/quickview-core/src/cache.rs, crates/quickview-core/Cargo.toml, crates/quickview/Cargo.toml
Expanded unit tests cover key variation, round trips, misses, and corrupt entries; serde_json and tempfile dependencies added.
OCR worker cache integration
crates/quickview-ui/src/windows/shared.rs
ViewerController::start_ocr checks the on-disk cache before running tesseract, and stores newly parsed results after a miss, logging (not failing) on write errors.
Documentation and ADR updates
adrs/ADR-0009-Caching.md, docs/DECISIONS.md, docs/DEPENDENCIES.md, docs/PHASED_PLAN.md, .claude/CLAUDE.md
Documents the on-disk cache design, key derivation, atomic writes, no-eviction v1 policy, and deferred SQLite cache in Phase 8.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ViewerController
  participant CacheModule
  participant Filesystem
  participant Tesseract

  ViewerController->>CacheModule: load_ocr(cache_dir, file, lang)
  CacheModule->>Filesystem: read <cache_dir>/ocr/<key>.json
  alt cache hit
    Filesystem-->>CacheModule: cached JSON
    CacheModule-->>ViewerController: OcrResult
  else cache miss
    Filesystem-->>CacheModule: missing/error
    CacheModule-->>ViewerController: None
    ViewerController->>Tesseract: run_tesseract_tsv
    Tesseract-->>ViewerController: TSV output
    ViewerController->>ViewerController: parse_tesseract_tsv
    ViewerController->>CacheModule: store_ocr(cache_dir, file, lang, result)
    CacheModule->>Filesystem: write temp file, rename
  end
Loading

Poem

A rabbit hops through cache-lined burrows deep,
No more re-scanning while Tesseract sleeps,
Blake3 keys the path, the size, the time,
JSON snug in files, atomic and sublime. 🐇
Hop once, cache twice — the burrow grows! 📁

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: wiring up the on-disk OCR cache.
Description check ✅ Passed It includes the required summary and substantial implementation/testing details, though the checklist section is not provided in the template format.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ocr-cache

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d492353b83

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/quickview-core/src/cache.rs Outdated
/// incompatible older schema) is treated as a miss: OCR re-runs and the entry
/// is overwritten.
pub fn load_ocr(cache_root: &Path, file: &Path, lang: &str) -> Option<OcrResult> {
let path = ocr_cache_path(cache_root, file, lang);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include subsecond mtimes in OCR cache keys

This lookup trusts ocr_cache_path, but that key only hashes modified().as_secs() plus the file size. In workflows that rewrite the same image path within the same second with an unchanged byte length (common for rapid screenshot/editor saves), the cache key stays identical and this new on-disk cache can return OCR from the previous contents instead of re-running Tesseract. Include subsecond mtime data (or another content/version check) before treating the entry as a hit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in f886e19: the key now hashes the full nanosecond mtime (as_nanos()), so a same-second rewrite with an unchanged byte length produces a new key. Added a test asserting a 1ns mtime change (same second, same size) changes the key.

// re-run tesseract); failures are not, so transient errors
// retry on the next open.
if let Some(root) = &cache_root {
if let Err(err) = cache::store_ocr(root, &path, &lang, &parsed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid caching OCR after the source file changes

Because store_ocr recomputes the cache path from the file's current metadata after Tesseract has already read and parsed the image, an edit that lands while OCR is running can write OCR for the old contents under the new file's metadata key. Subsequent opens of the edited image then get a cache hit with stale word boxes/text. Snapshot the metadata/key before running Tesseract and either store under that key or skip the store if the file changed during OCR.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in f886e19: the cache key is now snapshotted before tesseract runs (load_ocr/store_ocr take the entry path derived once up front). A file edited mid-OCR stores its stale result under the old key, which the edited file then correctly misses.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/quickview-core/src/cache.rs`:
- Around line 63-83: The temp file cleanup in store_ocr only handles the rename
failure path, so a failed std::fs::write can leave an orphaned json.tmp-* file
behind. Update store_ocr to remove the temp file whenever the write step fails
as well, using the existing tmp path and the same cleanup behavior already used
after std::fs::rename errors.

In `@crates/quickview-ui/src/windows/shared.rs`:
- Around line 227-247: The OCR cache write path in store_ocr still uses a shared
json.tmp-<pid> temp file, so concurrent OCR jobs can overwrite each other and
lose a cache write. Update the cache write flow called from shared.rs to
generate a unique temp file per store_ocr call (or otherwise serialize writes)
while keeping the existing cache key behavior, and use the unique temp path
during the atomic replace step so overlapping jobs cannot clobber one another.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba828bd0-a69e-4a1d-a3d2-945037a2588a

📥 Commits

Reviewing files that changed from the base of the PR and between a2c5e9f and d492353.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • .claude/CLAUDE.md
  • adrs/ADR-0009-Caching.md
  • crates/quickview-core/Cargo.toml
  • crates/quickview-core/src/cache.rs
  • crates/quickview-ui/src/windows/shared.rs
  • docs/DECISIONS.md
  • docs/DEPENDENCIES.md
  • docs/PHASED_PLAN.md
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Use rustfmt and clippy for code formatting and linting, as tracked in rust-toolchain.toml
Run clippy with all targets and features enabled with -D warnings flag to treat warnings as errors

Files:

  • crates/quickview-ui/src/windows/shared.rs
  • crates/quickview-core/src/cache.rs
🔇 Additional comments (10)
crates/quickview-core/src/cache.rs (4)

1-14: LGTM!


24-45: LGTM!

Explicit cache_root parameterization is a clean improvement for testability, and the hash inputs (path, lang, mtime, size) reasonably invalidate on file changes.


47-56: LGTM!


85-187: 📐 Maintainability & Code Quality

No MSRV issue here The crate targets Rust 1.83 and the pinned toolchain is stable, so Option::is_none_or is available here.

			> Likely an incorrect or invalid review comment.
crates/quickview-core/Cargo.toml (1)

14-20: LGTM!

adrs/ADR-0009-Caching.md (1)

38-62: LGTM!

docs/DECISIONS.md (1)

32-32: LGTM!

Also applies to: 132-140

docs/DEPENDENCIES.md (1)

37-37: LGTM!

docs/PHASED_PLAN.md (1)

160-164: LGTM!

.claude/CLAUDE.md (1)

54-61: LGTM!

Comment thread crates/quickview-core/src/cache.rs Outdated
Comment thread crates/quickview-ui/src/windows/shared.rs
- hash nanosecond mtime: whole seconds aliased a same-second rewrite
  of the same path with an unchanged byte length (Codex)
- snapshot the cache key before tesseract runs: a file edited mid-OCR
  now stores its stale result under the old key, which the edited file
  correctly misses, instead of under the new metadata's key (Codex);
  load_ocr/store_ocr now take the entry path derived once up front
- unique temp file per store (pid + per-process counter) so concurrent
  same-key writes cannot clobber each other's temp file (CodeRabbit)
- remove the temp file when the write itself fails, not just the
  rename — v1 has no eviction pass to sweep orphans (CodeRabbit)
- test: subsecond mtime change (same second, same size) changes the key

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

hasher.update(file.as_os_str().as_encoded_bytes());

P2 Badge Include the cwd in relative cache keys

When the viewer is launched with a relative path, this hashes only the literal path string. I checked crates/quickview/src/main.rs, and CLI/stdin input is stored as PathBuf::from(...) without canonicalization, so quickview image.png run from two different directories can reuse the same cache entry when the two files also share lang, size, and mtime, causing the second image to load OCR boxes/text from the first. Canonicalize or otherwise make the keyed path absolute before consulting the on-disk cache.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The OCR cache key hashes the literal path string, so a relative
invocation ('quickview image.png') keyed on the cwd-dependent spelling:
two different files could collide on one entry (given equal lang, size,
and mtime), and one file opened via different spellings fragmented into
several. Relative paths also broke directory navigation, since
"image.png".parent() is empty.

Canonicalize once in resolve_input_path so every downstream consumer
sees one absolute, symlink-resolved form. Missing files fall through
unchanged, preserving the graceful load-failed state.
@green2grey

Copy link
Copy Markdown
Member Author

Re: Codex's relative-path cache-key finding on f886e19 — valid, fixed in b4c327d. Verified the claim first: resolve_input_path stored the raw CLI/stdin string and nothing downstream canonicalized, so quickview image.png keyed on the literal cwd-dependent spelling (collision possible given equal lang/size/mtime — realistic for archive-extracted or cp -p copies with truncated mtimes — plus guaranteed cache fragmentation across spellings of the same file). Fixed at the app boundary rather than inside ocr_cache_path: the input path is canonicalized once in resolve_input_path (missing files fall through unchanged to keep the graceful load-failed state). This also fixes a pre-existing bug the verification surfaced: relative invocations had a broken parent dir ("image.png".parent() is empty), which silently disabled arrow-key directory navigation. Smoke-tested: a relative-path run from a different cwd now hits the entry written by an absolute-path run, with no new cache file created.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4c327d673

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/quickview-core/src/cache.rs Outdated
static WRITE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = entry.with_extension(format!("json.tmp-{}-{seq}", std::process::id()));
if let Err(err) = std::fs::write(&tmp, &json) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict OCR cache file permissions

With a normal 022 umask, std::fs::write creates this temp/cache file as 0644 before it is renamed. In environments where $XDG_CACHE_HOME or the home directory is traversable (shared machines or custom cache roots), the new persistent cache exposes recognized text from screenshots/images to other local users. Please create the temp file with private permissions (for example 0600, and ideally a 0700 cache dir) before writing/renaming.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in 4d22e87. Confirmed empirically first: entries were landing 0644 in 0755 dirs under umask 022, relying on the home directory for privacy — and this cache is the first place QuickView persists OCR text, so private-by-default is the right posture. Temp files are now created with mode 0600 via OpenOptionsExt (create_new; the rename preserves the mode on the final entry) and cache directories with mode 0700 via DirBuilderExt. Creation-time only — pre-existing entries keep their old modes; ADR-0009 documents that clearing ~/.cache/quickview/ocr resets everything. Verified on disk: fresh run yields a 0700 dir and 0600 entry, and the round-trip test now asserts both modes.

Cache entries hold recognized text from the user's images (screenshots
often contain emails, tokens, passwords) and are the first place
QuickView persists OCR text; with umask 022 they landed world-readable
(0644 in 0755 dirs), relying on the home directory for privacy.

Create the temp file with mode 0600 (rename preserves it) and cache
directories with mode 0700. Creation-time only; pre-existing entries
keep their modes — documented in ADR-0009 with the cache-clear reset.
@green2grey green2grey merged commit 3075110 into main Jul 6, 2026
3 checks passed
@green2grey green2grey deleted the feat/ocr-cache branch July 6, 2026 06:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant