agentleFS
fjrevoredofjrevoredo/mini-diarium/src-tauri/CLAUDE.md

mini-diarium / src-tauri

CLAUDE.md318 starsChanged 12 days agoMITView on GitHub

For project architecture, command registry, and cross-cutting conventions see the root CLAUDE.md. For durable backend rules, use Rust best practices and Tauri best practices before changing commands, auth policy, migrations, encrypted storage, IPC, or WebView security. The backend is a Cargo workspace (root Cargo.toml) with three members, layered bottom-up: mini-diarium-crypto (base) → mini-diarium-core (depends on crypto) → mini-diarium (app, depends on core):

The file

We split this CLAUDE.md into the rules it gives an agent

11 rules. About 0 are common, 0 are distinctive, and 11 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    All commands return Result<T, String>.

    line 75
  2. Very specific:

    All commands that access entries must ensure the journal is unlocked.

    line 92
  3. Very specific:

    Create crates/mini-diarium-core/src/import/FORMAT.rs — parser returning Vec<DiaryEntry>

    line 112
  4. Very specific:

    Add pub(crate) mod FORMAT.

    line 113
  5. Very specific:

    Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin, and register it in register_all()

    line 114
  6. Very specific:

    All commands that access entries must check db_state.as_ref().ok_or("Diary not unlocked")?

    line 138
  7. Very specific:

    Write the function in the appropriate src-tauri/src/commands/*.rs file (or create a new module and add it to commands/mod.rs)

    line 203
  8. Very specific:

    Add typed wrapper in the matching command-category sub-file under src/lib/tauri/

    line 205
  9. Very specific:

    Create crates/mini-diarium-core/src/import/FORMAT.rs with a parse_FORMAT(content: &str) -> Result<Vec<DiaryEntry>, String> function

    line 236
  10. Very specific:

    Add pub(crate) mod FORMAT.

    line 237
  11. Very specific:

    Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin (or ExportPlugin), register in …

    line 238

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

Backend (src-tauri/) — Mini Diarium

For project architecture, command registry, and cross-cutting conventions see the root CLAUDE.md. For durable backend rules, use Rust best practices and Tauri best practices before changing commands, auth policy, migrations, encrypted storage, IPC, or WebView security.

Workspace layout

The backend is a Cargo workspace (root Cargo.toml) with three members, layered bottom-up: mini-diarium-crypto (base) → mini-diarium-core (depends on crypto) → mini-diarium (app, depends on core):

  • App crate — src-tauri/ (mini-diarium): the Tauri commands (commands/*), native menu, OS shell, WebView security, and window/screen-lock listeners. Depends on the core crate; lib.rs re-exports its modules (pub use mini_diarium_core::{auth, backup, config, crypto, db, export, import, plugin, search};) so crate::db::…-style paths in commands/* resolve. As of open-core M2 (TODO-0077), commands/* reach core only through its curated façade — the module roots (db, auth, export, import, plugin) re-export the curated API and seal their internals to pub(crate). Do not reintroduce crate::db::queries::… / crate::db::schema::… / db.conn() / db.key() — or a raw rusqlite::Connection — in commands/*; use the façade names (e.g. crate::db::insert_entry_with_images, crate::auth::add_password_slot, crate::db::peek_auth_slot_types). The app crate no longer depends on rusqlite at all (bundled SQLite arrives transitively through the core crate), so a direct driver call is now a compile error rather than a convention violation. The raw entry-row primitives db::insert_entry / db::update_entry (no lock check, no image links) are exported only with core's test-support feature, which the app enables only as a dev-dependency; only a non-test build (cargo build --workspace, CI "Build Tauri app") enforces that gate — cargo test and cargo clippy --all-targets unify test-support into core, so a production call to a gated name still compiles there. Two greps must stay empty: rg "rusqlite" src-tauri/src and rg "db::queries::|db::schema::|\.conn\(\)|\.key\(\)" src-tauri/src. The surface is documented in crates/mini-diarium-core/API.md, which is explicitly pre-1.0 and internal — see its "Contract & compatibility" section for the error/serde/handle rules that come with it.
  • Core crate — crates/mini-diarium-core/ (mini-diarium-core): the Tauri-free SQLite business layer — db, import/export, plugins, backup, config, search. Zero tauri:: references. It depends on mini-diarium-crypto and re-exports its crypto/auth modules, so mini_diarium_core::crypto::… / …::auth::… (and the app-crate shim) still resolve. Consumable by a future separate product and eventually WASM (see docs/OPEN_CORE_STRATEGY.md). Its curated public surface is documented in API.md (pre-1.0, internal until open-core M4 decides distribution); everything else is pub(crate)/private. Its version is intentionally decoupled from the app version.
  • Crypto crate — crates/mini-diarium-crypto/ (mini-diarium-crypto): the reusable, rusqlite-free cryptographic base — AES-256-GCM cipher, Argon2id password hashing, and the X25519/HKDF master-key wrapping (crypto/ + the pure auth/ methods and their SecretBytes/KeypairFiles value types). Carved out in open-core M3a (TODO-0082) so the universal cryptographic code compiles without the desktop SQLite binding in its dependency graph, keeping the WASM door open. Acceptance invariant: cargo tree -p mini-diarium-crypto shows no rusqlite. Surface documented in crates/mini-diarium-crypto/API.md. The db-coupled pieces (AuthMethodInfo, add_password_slot/add_keypair_slot, the encrypted-row format) stay in the core crate.

Cargo.lock and target/ live at the repo root. Run all backend tests with cargo test --workspace (not --manifest-path src-tauri/Cargo.toml, which skips the core and crypto crates' tests).

Key Modules

App crate (src-tauri/src/)

PathPurpose
commands/One module per command group — all registered via generate_handler![] in lib.rs. Groups: auth/ (multi-file), entries, search, navigation, stats, import, export, plugin, debug, files, fonts, images, attachments, menu, tags. Handlers delegate to the core crate.
webview_security/Platform WebView handlers that block external HTTP(S) at the OS level
menu.rsNative menu builder (Preferences + Quit only, plus the macOS PredefinedMenuItem Edit/Window submenus) and the menu-preferences event emitter
screen_lock.rsOS session-lock listener → auto-lock trigger
spellcheck.rsDictionary-language resolution + the Linux-only WebView enablement (see Gotcha #9)
log_capture.rsBounded in-memory ring buffer of recent log records, feeding the debug dump (see Gotcha #10)
sync_detect.rsCloud-sync-folder heuristics for the debug dump; returns a tool name, never a path

Core crate (crates/mini-diarium-core/src/)

PathPurpose
db/schema/DB connection helpers (open_connection*), DDL, and schema migrations v1_to_v2 … v13_to_v14 via apply_pending
db/queries/Encrypted-row assembly / SQL binding (desktop adapter). The field codec (encrypt_for_storage, decrypt_utf8, decrypt_bytes) now lives in the crypto crate's format module (M3b / TODO-0083); mod.rs re-exports it under the historical names so call sites are unchanged
auth/Composed slot ops (add_password_slot/add_keypair_slot) + AuthMethodInfo DTO. Re-exports the pure auth methods from the mini-diarium-crypto crate (see below).
import/Built-in diary format parsers (Mini Diary, Day One, jrnl)
export/JSON and Markdown export writers
plugin/Plugin trait, registry, Rhai script loader and sandbox
backup/Encrypted-journal snapshots: policy.rs (pure retention/dedup decisions — no I/O, no clock), store.rs (the only std::fs in the subsystem: VACUUM INTO, fsync, atomic rename, verification, naming), manifest.rs (the plaintext manifest.json sidecar + adoption of pre-upgrade files), mod.rs (orchestration). See Gotcha #11
config.rsJournalConfig/JournalInfo and config.json handling

Crypto crate (crates/mini-diarium-crypto/src/)

rusqlite-free; re-exported by the core crate as crypto/auth.

PathPurpose
crypto/cipher.rsAES-256-GCM encrypt/decrypt + keyed HKDF-SHA256 fingerprints (tag_name_fingerprint, image_fingerprint)
crypto/password.rsArgon2id password hashing (hash_password, verify_password, derive_key_from_phc_hash, generate_salt)
format.rsAt-rest encrypted-row field codec (encrypt_for_storage, decrypt_utf8, decrypt_bytes) — thin cipher-backed wrapper, re-exported by core as format (M3b / TODO-0083)
auth/password.rsPasswordMethod — master-key wrap/unwrap via Argon2id + AES-256-GCM
auth/keypair.rsKeypairMethod/PrivateKeyMethod — X25519 ECIES wrap/unwrap; generate_keypair, derive_public_key
auth/auto_key.rsAutoKeyMethod — device-bound 32-byte key wrap/unwrap (no KDF)
auth/mod.rsSecretBytes (zeroize-on-drop) + KeypairFiles value types; re-exports the method types

Conventions

Command Pattern

#[tauri::command]
pub fn my_command(arg: String, state: State<DiaryState>) -> Result<ReturnType, String> {
    with_unlocked_db(&state, |db| {
        // ... business logic
        Ok(result)
    })
}

with_unlocked_db acquires the DB lock and checks that the journal is unlocked, returning canonical error strings ("Journal state lock failed" / "Journal must be unlocked"). Use it for any command that only needs the DB connection. For commands that also access other DiaryState fields (e.g. app_data_dir, db_path) alongside the DB lock, open-code the preamble to avoid restructuring the borrow.

All commands return Result<T, String>. Register in both commands/mod.rs and generate_handler![] in lib.rs.

A command that handles multi-megabyte data is async and runs its command core through commands::run_blocking, because on Windows a synchronous command blocks the WebView2 event thread. A command that releases the DB mutex and takes it again re-checks the connection's session_id() with with_same_session, never only the path (TODO-0134; see Tauri best practices "Keep Heavy Work Off The Event Thread", which also lists the commands that are still synchronous).

For command design rules that should not regress, see:

  • Tauri best practices for command registration, IPC validation, mapped error strings, and testable command cores.
  • Rust best practices for backend-owned invariants, lock scope, encrypted row helpers, migrations, and compatibility shims.

Two delete commands, plus a read-only content check — use the right one:

  • delete_entry_if_empty(id, title, text) — soft delete: only removes the entry if both the incoming title/text arguments and the entry's currently-persisted on-disk row are blank (TODO-0104 added the on-disk check; previously it trusted the incoming arguments alone). Returns bool. Used by the editor on blur/navigation to silently clean up orphaned blank entries — this on-disk check is the safety net that protects paths which can never show a confirm dialog (auto-lock, beforeunload/app close).
  • delete_entry(id) — hard delete: unconditional removal, returns an error if the entry is not found. Used for explicit user-initiated "delete entry" actions, and by the frontend's entry-persistence consent-gate guard (TODO-0104) after the user confirms an in-app dialog.
  • entry_has_content(id) — read-only: returns whether the entry's on-disk row currently holds real content, without mutating anything. Lets the frontend guard decide whether to show the consent dialog before calling delete_entry, using the same on-disk truth delete_entry_if_empty checks — not the frontend's in-memory copy, which can be stale.

Error Handling

  • Result<T, String> — map errors with .map_err(|e| format!(...)).
  • All commands that access entries must ensure the journal is unlocked. Use with_unlocked_db (canonical error strings: "Journal state lock failed" and "Journal must be unlocked") wherever the command only needs the DB handle. Canonical strings are matched by mapTauriError on the frontend.
  • Security-sensitive command input must be validated in Rust even when the frontend already shapes the UI. Collections representing auth slots, credentials, or policy requirements need duplicate and coverage checks where identity matters.

Menu Event Pattern — Backend

Emit events in menu.rs; the frontend listens in MainLayout.tsx or overlay components:

app.emit("menu-preferences", ())

Menu event names are prefixed menu-. Since TODO-0065 reduced the native menu to Preferences + Quit, menu-preferences is the only event emitted — every other action lives in the WebView (Header controls, the ⋮ overflow menu, and the JS keyboard shortcuts in src/lib/keyboard-shortcuts.ts). Preferences is also the only remaining OS-level accelerator (CmdOrCtrl+,). See root CLAUDE.md for the full cross-layer pattern.

Import Parser Pattern (Built-in)

To add a new built-in import format (compiled Rust):

  1. Create crates/mini-diarium-core/src/import/FORMAT.rs — parser returning Vec<DiaryEntry>
  2. Add pub(crate) mod FORMAT; to crates/mini-diarium-core/src/import/mod.rs (import parsers are sealed to the crate as of open-core M2; the only public import path is via the plugin registry)
  3. Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin, and register it in register_all()

The plugin system (run_import_plugin) is the single entry point; no per-format Tauri command is needed. The search reindex hook lives in commands::import::import_entries (see // Search index hook: comment).

For user-scriptable formats, users drop a .rhai file in {app_data_dir}/plugins/. See crates/mini-diarium-core/src/plugin/rhai_loader/ for the Rhai script contract (metadata.rs parses the // @name/// @type header, runtime.rs holds the sandboxed engine) and docs/user-plugins/USER_PLUGIN_GUIDE.md for the end-user plugin guide and templates.

Verification Commands

For the canonical post-task checklist (tests + formatting + CHANGELOG + TODO), see Post-Task Completion Best Practices.

Backend-specific:

cargo bench --manifest-path src-tauri/Cargo.toml                        # All Rust benchmarks (criterion)
cargo bench --manifest-path src-tauri/Cargo.toml --bench cipher_bench   # Specific benchmark

Security Rules

  • Never log, print, or serialize passwords or encryption keys
  • Never store plaintext diary content in any unencrypted form on disk
  • Never send data over the network — no analytics, no telemetry, no update checks
  • Auth: A random master key is wrapped per auth slot in auth_slots (schema v3). Password slots use Argon2id + AES-256-GCM wrapping; keypair slots use X25519 ECIES. The master key is never stored in plaintext.
  • The DiaryState holds Mutex<Option<DatabaseConnection>> — None when locked, Some when unlocked
  • All commands that access entries must check db_state.as_ref().ok_or("Diary not unlocked")?
  • unlock_diary_auto intentionally bypasses require_all_auth: Local-only journals use a device-bound key stored in config.json and have no user credential to combine with a second factor. The multi-auth guard only applies to password/keypair journals. Applying it to auto-key journals would always fail and is not the intended policy. See commands/auth/auth_core.rs unlock_diary_auto doc comment and docs/decisions/2026-04-passwordless-journal.md for the full rationale (P20, 2026-05-21 Position A decision).

Network Isolation Defense-in-Depth Stack (v0.5.0)

The following layers prevent the embedded WebView from making outbound network requests:

  1. on_navigation handler (lib.rs) — blocks any URL navigation that is not tauri://, ipc://, or localhost. This was in place before v0.5.0.
  2. CSP (tauri.conf.json) — explicit connect-src 'self' ipc: http://ipc.localhost; worker-src 'none'; child-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; manifest-src 'none'.
  3. Init script (lib.rs — initialization_script_for_all_frames) — nulls RTCPeerConnection, WebTransport, Worker, SharedWorker, navigator.serviceWorker, navigator.sendBeacon, navigator.connection, and window.open in all frames before any page script runs. fetch/XMLHttpRequest/WebSocket/EventSource stay available because Tauri IPC and the dev server depend on them; external network requests are blocked by CSP and platform WebView handlers. Source of truth: src/lib/network-isolation-script.ts — keep the two copies in sync.
  4. on_new_window(Deny) (lib.rs) — blocks window.open() and target="_blank" popup creation on all platforms.
  5. Windows WebResourceRequested (lib.rs #[cfg(target_os = "windows")]) — COM event handler that returns a synthetic 403 Forbidden response (SetResponse) for external HTTP(S) requests (non-localhost).
  6. macOS WKContentRuleList (lib.rs #[cfg(target_os = "macos")]) — compiled content-blocking rule that blocks all https?://.* requests to non-localhost domains at the WebKit engine level.
  7. Linux Flatpak (flatpak/io.github.fjrevoredo.mini-diarium.yml) — no --share=network in finish-args → kernel namespace blocks all outbound sockets.

Opener exception: tauri_plugin_opener is retained. User-clicked help/docs links open in the system browser. This is documented in PHILOSOPHY.md and SECURITY.md.

Gotchas and Pitfalls

  1. Current schema is v14: entries_fts was removed in v4 for security (it stored plaintext). v5 added id INTEGER PRIMARY KEY AUTOINCREMENT for multi-entry-per-date support. v6 added the db_settings table (require_all_auth flag + HKDF-SHA256 MAC) to bind multi-auth requirement to the encrypted database rather than config.json. v7 added tags (AES-256-GCM encrypted name, HKDF-SHA256 keyed fingerprint for dedup) and entry_tags (association table with ON DELETE CASCADE on both sides). v8 added custom_fonts (unencrypted BLOB storage for user-uploaded font files; UNIQUE(family, weight) constraint; Regular weight required before Bold). v9 added entry_metadata_encrypted (nullable BLOB on the entries table, encrypted with the journal master key, context "entry_metadata") to store per-entry metadata such as font family and font size. v10 added images (content-addressed encrypted image store; HKDF-SHA256 fingerprint for dedup; AES-256-GCM encrypted data BLOB) and entry_images (junction table with ON DELETE CASCADE on entry_id, ON DELETE RESTRICT on image_id). v11 added thumbnail_data, thumbnail_mime_type, width, height, byte_size, thumbnail_version columns to images (nullable; backfilled lazily). v12 added preview_enc BLOB (nullable; AES-256-GCM encrypted 200-char plaintext preview; context "entry_preview"; populated by insert_entry/update_entry on every save; NULL for entries not yet saved since v12 upgrade; get_entries_for_timeline falls back to full-text decryption when NULL). v13 added locked INTEGER NOT NULL DEFAULT 0 (plaintext, non-sensitive per-entry "lock against accidental edits" flag, TODO-0071; not a security boundary — never routed through the content-save path). It is toggled by the dedicated set_entry_locked command (targeted UPDATE, no re-encryption); get_locked_entry_dates feeds the calendar indicator. Core owns the lock rule, not the commands: update_entry_with_images, delete_entry_by_id, add_tag_to_entry, remove_tag_from_entry, add_attachment_to_entry, and remove_attachment_from_entry check it inside their write unit and return db::ERR_ENTRY_LOCKED (exactly "entry is locked"). Commands must pass that error through unwrapped (mapTauriError matches it exactly) and must not add their own pre-check; delete_entry_if_empty maps it to Ok(false) because its auto-lock and app-close callers cannot show an error. The raw primitives insert_entry / update_entry skip the lock and the image links, so the façade exports them only with test-support (fixtures and benches); production code uses the *_with_images writes. See crates/mini-diarium-core/API.md ("Entry lock"). recalculate_all_word_counts (TODO-0111, manual bulk word-count rescan) takes the opposite stance on a locked row: it skips it (counted in skipped_locked) rather than rejecting the whole command, since the operation's purpose is to fix as many entries as it safely can in one pass. v14 added attachments (content-addressed encrypted file store, TODO-0114: HKDF-SHA256 fingerprint for dedup via image_fingerprint — safe to reuse because it is a separate table; AES-256-GCM encrypted data; plaintext mime_type + byte_size) and entry_attachments (link table; the per-entry file name is encrypted on the link row; ON DELETE RESTRICT on both entry_id and attachment_id — TODO-0123: entry_id must not cascade, so an older app that treats an attachment-only entry as empty fails to delete it instead of dropping its links; delete_entry_by_id removes the links explicitly before the entry). Ownership is explicit: the links change only through add_entry_attachment/remove_entry_attachment, per-entry backup restore (crate-private link_attachment), and delete_entry_by_id — the entry save path never scans HTML for attachments. Per-entry restore is atomic per entry: blobs, row, links, and tags of one entry commit together or not at all. Orphan blobs are removed by cleanup_orphaned_attachments on remove and in delete_entry_by_id. An entry with attachments is never "empty": delete_entry_if_empty refuses it and entry_has_content reports true. Migrations run v3→v4 … v13→v14 via apply_pending. Every bump must also decide the forward-compatibility marker — see Gotcha #14. insert_entry, update_entry, delete_entry, and all import commands have // Search index hook: comments marking where a future search module should be plugged in.
  2. Always use open_connection / open_connection_in_memory — never Connection::open directly: PRAGMA foreign_keys is a per-connection setting that SQLite resets on every new connection. Forgetting to set it means ON DELETE CASCADE / ON DELETE RESTRICT declarations on entry_tags, entry_images, and any future tables are silently inert. The helpers in db/schema/create.rs (open_connection(path) for file DBs, open_connection_in_memory() for test DBs) apply the pragma automatically. In tests that construct DatabaseConnection by hand, call open_connection_in_memory() rather than Connection::open_in_memory(). Never bypass this with a direct Connection::open call.
  3. Command registration is two places: New commands must be added to both commands/mod.rs (module declaration) and generate_handler![] in lib.rs. Missing either causes silent failures or compile errors.
  4. Import behavior (no merge): Parsers in import/*.rs return Vec<DiaryEntry>. Imports always create new entries; there is no date-conflict merging. Re-importing the same file creates duplicate entries. The old merge path has been removed from the current codebase.
  5. Auth slots (v3 schema): Each auth method stores its own wrapped copy of the master key in auth_slots. remove_auth_method refuses to delete the last slot (minimum one required). change_password re-wraps the master key in O(1) — no entry re-encryption needed. verify_password exists as a side-effect-free check used before multi-step operations. The require_all_auth flag (v6 schema) lives in the db_settings table inside diary.db, integrity-protected by an HKDF-SHA256 MAC derived from the master key — tampering with the row enforces the guard via a fail-safe. See docs/decisions/2026-05-settings-storage-taxonomy.md for the full settings storage taxonomy and when to use db_settings vs. config.json.
  6. Plugin registry is initialized once at startup in lib.rs .setup(). It reads {app_data_dir}/plugins/ for .rhai scripts (central location, shared across all journals). The registry is stored as State<Mutex<PluginRegistry>>. Lock it only through commands::plugin::lock_registry, which recovers the mutex after a plugin panic (TODO-0147).
  7. Rhai's export keyword is reserved: Export plugin scripts must use fn format_entries(entries) instead of fn export(entries). The RhaiExportPlugin wrapper calls "format_entries" internally.
  8. Rhai AST requires unsafe impl Send + Sync: The rhai::AST type does not implement Send + Sync in the current version. The unsafe impls on RhaiImportPlugin and RhaiExportPlugin are required and justified: AST is immutable after compilation, and Engine is created fresh per invocation.
  9. WebView-level spell checking is a Linux-only concern (TODO-0081, issue #227): WebView2 and WKWebView route the HTML spellcheck attribute to an OS-native text checker, so on Windows/macOS the frontend attribute is the whole feature. WebKitGTK runs no checker until set_spell_checking_enabled(true) is called on the WebKitWebContext, which defaults to off — so spellcheck::apply has a #[cfg(target_os = "linux")] body and is a deliberate no-op elsewhere. webkit2gtk is a direct dependency only under [target.'cfg(target_os = "linux")'.dependencies]; it must stay version-compatible with tauri's so PlatformWebview::inner() returns the same webkit2gtk::WebView type. Dictionaries are data, not code: the Flatpak manifest installs them to /app/share/hunspell/, other Linux packages rely on /usr/share/hunspell. tauri dev is not a Flatpak dictionary test: it uses host dictionaries, so install the active language's hunspell-* package before using it to validate spellcheck. Keep DEFAULT_REGIONS in spellcheck.rs, the hunspell-dicts module in flatpak/io.github.fjrevoredo.mini-diarium.yml, and the shipped locale list in src/i18n/locales/ in sync — a UI language with no matching dictionary silently checks nothing. One documented exception: Chinese (Simplified) has no maintained Hunspell zh_CN dictionary, so the Flatpak ships none and the Linux warning is expected.
  10. The global logger is a capture tap, and Info-and-above records reach users (TODO-0090): lib.rs installs env_logger via .build() + log_capture::install(...) instead of .init(). Stderr behaviour is unchanged, but every Info/Warn/Error record is also kept in a 200-entry ring buffer and serialized into the debug dump. Three invariants:
    • Never interpolate user data into info!/warn!/error!. Journal names, auth-slot labels, tag names, and entry titles must not appear at those levels — no regex can recognise them, so the fix is always at the call site (see the id-only info! in commands/auth/auth_journals.rs). Paths are the one exception: log_capture::redact scrubs the home directory and absolute-path-shaped runs on read.
    • Debug/Trace are never captured, by design — that is where path- and entry-shaped detail is allowed to live.
    • Nothing inside the ring buffer's mutex may log, or CapturingLogger::log re-enters its own lock and deadlocks. The dump's privacy boundary is enforced by test_build_debug_dump_leaks_nothing_sensitive in commands/debug.rs, which asserts the serialized dump carries no key material, no user-chosen string, and no path shape. A new dump field that fails it is the thing that is wrong.
  11. A snapshot must precede the risky write, and must not block the UI (TODO-0098). The backup engine lives in core (backup/); commands/backup_triggers.rs is the app-side wiring that decides when it runs. Four constraints:

    • Failure semantics are deliberately asymmetric. A failed snapshot is logged and swallowed everywhere except before a schema migration, where db::schema::open aborts the migration. A missing backup is recoverable; a half-migrated journal is not. Do not "fix" that inconsistency.
    • The lock path moves the connection, it does not share it. lock_diary_inner takes the DatabaseConnection out of DiaryState and hands it to a worker thread, so the backend reads as locked the instant it returns while the snapshot finishes against the moved handle. Any caller that then touches diary.db on the filesystem — moving or deleting it — must use LockCompletion::AwaitFileRelease, or the open handle makes the operation fail on Windows (os error 32). A poisoned state mutex never blocks a lock (TODO-0147): the connection is dropped without a snapshot, the receiver fires at once, and the db poison is cleared so the journal can be unlocked again.
    • The change counter is read from the live file and persisted in the manifest. VACUUM INTO rebuilds the database, so a snapshot's own counter is unrelated to its source's. Reading it back from a snapshot silently breaks deduplication; test_vacuum_into_resets_the_change_counter is the permanent guard.
    • manifest.json is plaintext and its contents are a privacy decision. Timestamps, counts, sizes, versions, and auth-slot types only — never entry content, titles, tag names, journal names, auth-slot labels, or any filesystem path. test_manifest_contains_no_user_content enforces it.

    Snapshot verification checks that the live master key decrypts content in the snapshot. It cannot "unwrap an auth slot": a wrapped_key is unwrapped by a credential to produce the master key, so holding the master key does not reverse it.

  12. Inspecting a snapshot opens a second decrypted database with a second master key (TODO-0098 Milestone 4). commands/backup_inspect.rs is the IPC surface; the engine is mini_diarium_core::backup::inspect. Three constraints:
    • The handle lives in DiaryState.inspection, not in its own managed state. That is what makes lock_diary_inner_with tear it down — every lock path (idle timer, OS session lock, focus loss, journal switch, app exit) funnels through that one function, so the invariant holds by call graph rather than by a caller remembering a second teardown. The teardown runs before the already-locked early return, which is what covers the journal-switch case.
    • The connection is read-only at the SQLite level, and no migration runs. Opening a snapshot as a journal writes to it — update_slot_last_used alone destroys the restore point (KI-11) — and migrating one would rewrite the copy taken because a migration was the risk. list_snapshot_entries adapts to whatever schema the snapshot carries instead.
    • A snapshot may need a different credential than the live journal. After a password change the snapshot keeps the old wrapped key (finding B-11), so check_backup_credentials compares the plaintext auth-slot rows and says so before the user is prompted. It needs no key and works while the journal is locked.
  13. Every open path calls the forward-compatibility guard first, and every schema bump must decide min_reader_version (TODO-0122, ADR). db/schema/compat.rs refuses a journal (ensure_journal_readable) or snapshot (ensure_backup_readable) whose schema_version > SCHEMA_VERSION, unless db_settings.min_reader_version parses to <= SCHEMA_VERSION. Three constraints:
    • A new open path must call the guard directly after opening the SQLite file, before any write (update_slot_last_used) and before the KDF — a refused file must stay byte-for-byte unchanged. migrate_with_pre_migration_snapshot repeats the check as a backstop, but it runs after the slot write.
    • A schema bump must decide the marker, in two places. Only a migration an older app can ignore while writing (no new invariant, FK, or column it must maintain) may keep older readers in by writing min_reader_version; anything else must DELETE the row. When in doubt, delete it. Write the state explicitly in the migration step (inside its transaction) and in create_schema — fresh journals never run migrations. v14 attachments would not have qualified (TODO-0123).
    • The tripwire test fails on every bump until the decision is recorded. Update MARKER_DECIDED_FOR_SCHEMA_VERSION and EXPECTED_MARKER in compat.rs tests; test_fresh_and_migrated_journals_agree_on_the_marker checks that create_schema and the migrations agree. The full checklist is the module doc of compat.rs.

Common Task Checklists

Adding a New Tauri Command

  1. Write the function in the appropriate src-tauri/src/commands/*.rs file (or create a new module and add it to commands/mod.rs)
  2. Register in lib.rs generate_handler![] macro
  3. Add typed wrapper in the matching command-category sub-file under src/lib/tauri/

If the command is not yet ready to ship, gate it behind the experimental feature instead of blocking the PR:

// In commands/your_module.rs — gate consumer-only imports too, or clippy -D warnings fails
#[cfg(feature = "experimental")]
use crate::commands::auth::DiaryState;
#[cfg(feature = "experimental")]
use tauri::State;

#[cfg(feature = "experimental")]
#[tauri::command]
pub fn your_command(_state: State<DiaryState>) -> Result<(), String> {
    todo!()
}
// In lib.rs generate_handler![] — Tauri's macro parses outer attributes before each path,
// so #[cfg] here compiles out both the match arm and all references inside it.
#[cfg(feature = "experimental")]
commands::your_module::your_command,

Activate during development: cargo build --features experimental. See docs/decisions/2026-06-feature-flags.md for the full strategy.

Adding a New Import/Export Format

Option A: Built-in (compiled Rust)

  1. Create crates/mini-diarium-core/src/import/FORMAT.rs with a parse_FORMAT(content: &str) -> Result<Vec<DiaryEntry>, String> function
  2. Add pub(crate) mod FORMAT; to crates/mini-diarium-core/src/import/mod.rs (parsers stay sealed to the crate; the plugin registry is the only public path)
  3. Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin (or ExportPlugin), register in register_all()

The plugin runner (run_import_plugin / run_export_plugin) dispatches to the builtin. No per-format Tauri command is required — the frontend discovers available formats via list_import_plugins() / list_export_plugins().

Option B: User-scriptable (Rhai)

Users drop a .rhai file in {diary_dir}/plugins/. The file must have a // @name, // @type, and optionally // @extensions comment header. Import scripts define fn parse(content) returning an array of entry maps; export scripts define fn format_entries(entries) returning a string. See docs/user-plugins/USER_PLUGIN_GUIDE.md for templates and crates/mini-diarium-core/src/plugin/rhai_loader/runtime.rs for the runtime.

Search

Full-text search was originally provided by a SQLite FTS5 table, removed in schema v4 (v0.2.0) because it stored diary content in plaintext, defeating the AES-256-GCM encryption. Search was reintroduced as an in-memory scan over decrypted entries: each query decrypts the journal entries (reusing queries::get_all_entries, the same decrypt path used by export/stats), matches case- and accent-folded terms with AND semantics, builds HTML-escaped <mark> snippets, and discards everything — nothing searchable is ever written to disk.

LayerFileWhat it provides
Core scancrates/mini-diarium-core/src/search/mod.rs: SearchResult + SearchResponse structs + search_entries(db, query) DB orchestration (open-core M2 façade). text.rs: DB-free, crypto-free term folding, matching, and snippet building
Rust commandsrc-tauri/src/commands/search.rsThin #[tauri::command] search_entries wrapper over mini_diarium_core::search::search_entries; re-exports SearchResult/SearchResponse
Frontend wrappersrc/lib/tauri/search.tsSearchResult interface + searchEntries(query) async function returning SearchResponse
Frontend statesrc/state/search.tssearchQuery, searchResults, isSearching signals
Frontend componentssrc/components/search/SearchOverlay.tsxPalette-style dialog mounting SearchBar + SearchResults (in MainLayout)
src/components/search/SearchBar.tsxDebounced input with a monotonic latest-wins guard
src/components/search/SearchResults.tsxResults list; click deep-links the entry via setSelectedEntryId

Trigger: Header search button + Cmd/Ctrl+F (MainLayout keydown handler). A result click sets the entry deep-link before the date so the editor opens the exact entry (a day can hold multiple entries), then switches mainView to editor and closes the overlay.

Indexing hook points (// Search index hook: comments) remain in db/queries/entries/{insert,update,delete}.rs and commands/import.rs. They are unused today because the in-memory scan needs no persistent index, but mark where a future index would plug in.

Performance: the scan is O(n) over all entries per query, debounced 500 ms on the client and capped at 200 results displayed to the UI; the backend returns total_matches (pre-truncation count) so the UI can report "Showing first 200 of 1200" when results exceed the cap. This is acceptable for the personal-journal scale this app targets. If a future index is added, it must satisfy the original constraint:

  1. No plaintext on disk — the index must be encrypted or derived in a way that does not expose entry content to raw file access (e.g. encrypted FTS / SQLCipher, an encrypted trigram index, or an in-memory index rebuilt at unlock time).
  2. Schema migration — bump SCHEMA_VERSION in db/schema/mod.rs and add a migration step in db/schema/migrations/.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.