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):
In this repository49 other files it gives its agents.
- .agents/skills/unsafe-checker/AGENTS.mdAGENTS.md
- benchmarks/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- e2e/CLAUDE.mdCLAUDE.md
- src/CLAUDE.mdCLAUDE.md
- website/CLAUDE.mdCLAUDE.md
- website/llms-full.txtllms.txt
- website/llms.txtllms.txt
- ci-gate-diagnosisSkill
- coding-guidelinesSkill
- domain-webSkill
- exploration-modeSkill
- flathub-maintenanceSkill
- m01-ownershipSkill
- m02-resourceSkill
- m03-mutabilitySkill
- m04-zero-costSkill
- m05-type-drivenSkill
- m06-error-handlingSkill
- m07-concurrencySkill
- m09-domainSkill
- m10-performanceSkill
- m11-ecosystemSkill
- m12-lifecycleSkill
- m13-domain-errorSkill
- m14-mental-modelSkill
- m15-anti-patternSkill
- manual-planningSkill
- runbooksSkill
- rust-call-graphSkill
- rust-code-navigatorSkill
- rust-dailySkill
- rust-deps-visualizerSkill
- rust-learnerSkill
- rust-refactor-helperSkill
- rust-skill-creatorSkill
- rust-symbol-analyzerSkill
- rust-trait-explorerSkill
- security-stanceSkill
- seo-auditSkill
- seo-performance-reviewSkill
- skill-improverSkill
- solidjsSkill
- sync-lockfilesSkill
- tauri-agent-devSkill
- tauri-v2Skill
- tiptapSkill
- todo-managerSkill
- unsafe-checkerSkill
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
- Very specific: line 75
All commands return Result<T, String>.
- Very specific: line 92
All commands that access entries must ensure the journal is unlocked.
- Very specific: line 112
Create crates/mini-diarium-core/src/import/FORMAT.rs — parser returning Vec<DiaryEntry>
- Very specific: line 113
Add pub(crate) mod FORMAT.
- Very specific: line 114
Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin, and register it in register_all()
- Very specific: line 138
All commands that access entries must check db_state.as_ref().ok_or("Diary not unlocked")?
- Very specific: line 203
Write the function in the appropriate src-tauri/src/commands/*.rs file (or create a new module and add it to commands/mod.rs)
- Very specific: line 205
Add typed wrapper in the matching command-category sub-file under src/lib/tauri/
- Very specific: line 236
Create crates/mini-diarium-core/src/import/FORMAT.rs with a parse_FORMAT(content: &str) -> Result<Vec<DiaryEntry>, String> function
- Very specific: line 237
Add pub(crate) mod FORMAT.
- Very specific: line 238
Add a builtin wrapper struct in crates/mini-diarium-core/src/plugin/builtins.rs implementing ImportPlugin (or ExportPlugin), register in …
≈ “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.
Files
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.rsre-exports its modules (pub use mini_diarium_core::{auth, backup, config, crypto, db, export, import, plugin, search};) socrate::db::…-style paths incommands/*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 topub(crate). Do not reintroducecrate::db::queries::…/crate::db::schema::…/db.conn()/db.key()— or a rawrusqlite::Connection— incommands/*; 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 onrusqliteat 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 primitivesdb::insert_entry/db::update_entry(no lock check, no image links) are exported only with core'stest-supportfeature, 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 testandcargo clippy --all-targetsunifytest-supportinto core, so a production call to a gated name still compiles there. Two greps must stay empty:rg "rusqlite" src-tauri/srcandrg "db::queries::|db::schema::|\.conn\(\)|\.key\(\)" src-tauri/src. The surface is documented incrates/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. Zerotauri::references. It depends onmini-diarium-cryptoand re-exports itscrypto/authmodules, somini_diarium_core::crypto::…/…::auth::…(and the app-crate shim) still resolve. Consumable by a future separate product and eventually WASM (seedocs/OPEN_CORE_STRATEGY.md). Its curated public surface is documented inAPI.md(pre-1.0, internal until open-core M4 decides distribution); everything else ispub(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 pureauth/methods and theirSecretBytes/KeypairFilesvalue 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-cryptoshows norusqlite. Surface documented incrates/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/)
| Path | Purpose |
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.rs | Native menu builder (Preferences + Quit only, plus the macOS PredefinedMenuItem Edit/Window submenus) and the menu-preferences event emitter |
screen_lock.rs | OS session-lock listener → auto-lock trigger |
spellcheck.rs | Dictionary-language resolution + the Linux-only WebView enablement (see Gotcha #9) |
log_capture.rs | Bounded in-memory ring buffer of recent log records, feeding the debug dump (see Gotcha #10) |
sync_detect.rs | Cloud-sync-folder heuristics for the debug dump; returns a tool name, never a path |
Core crate (crates/mini-diarium-core/src/)
| Path | Purpose |
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.rs | JournalConfig/JournalInfo and config.json handling |
Crypto crate (crates/mini-diarium-crypto/src/)
rusqlite-free; re-exported by the core crate as crypto/auth.
| Path | Purpose |
crypto/cipher.rs | AES-256-GCM encrypt/decrypt + keyed HKDF-SHA256 fingerprints (tag_name_fingerprint, image_fingerprint) |
crypto/password.rs | Argon2id password hashing (hash_password, verify_password, derive_key_from_phc_hash, generate_salt) |
format.rs | At-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.rs | PasswordMethod — master-key wrap/unwrap via Argon2id + AES-256-GCM |
auth/keypair.rs | KeypairMethod/PrivateKeyMethod — X25519 ECIES wrap/unwrap; generate_keypair, derive_public_key |
auth/auto_key.rs | AutoKeyMethod — device-bound 32-byte key wrap/unwrap (no KDF) |
auth/mod.rs | SecretBytes (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 incomingtitle/textarguments 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). Returnsbool. 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 callingdelete_entry, using the same on-disk truthdelete_entry_if_emptychecks — 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 bymapTauriErroron 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):
- Create
crates/mini-diarium-core/src/import/FORMAT.rs— parser returningVec<DiaryEntry> - Add
pub(crate) mod FORMAT;tocrates/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) - Add a builtin wrapper struct in
crates/mini-diarium-core/src/plugin/builtins.rsimplementingImportPlugin, and register it inregister_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 benchmarkSecurity 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
DiaryStateholdsMutex<Option<DatabaseConnection>>—Nonewhen locked,Somewhen unlocked - All commands that access entries must check
db_state.as_ref().ok_or("Diary not unlocked")? unlock_diary_autointentionally bypassesrequire_all_auth: Local-only journals use a device-bound key stored inconfig.jsonand 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. Seecommands/auth/auth_core.rsunlock_diary_autodoc comment anddocs/decisions/2026-04-passwordless-journal.mdfor 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:
on_navigationhandler (lib.rs) — blocks any URL navigation that is nottauri://,ipc://, or localhost. This was in place before v0.5.0.- CSP (
tauri.conf.json) — explicitconnect-src 'self' ipc: http://ipc.localhost;worker-src 'none';child-src 'none';frame-src 'none';object-src 'none';form-action 'none';manifest-src 'none'. - Init script (
lib.rs—initialization_script_for_all_frames) — nullsRTCPeerConnection,WebTransport,Worker,SharedWorker,navigator.serviceWorker,navigator.sendBeacon,navigator.connection, andwindow.openin all frames before any page script runs.fetch/XMLHttpRequest/WebSocket/EventSourcestay 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. on_new_window(Deny)(lib.rs) — blockswindow.open()andtarget="_blank"popup creation on all platforms.- Windows
WebResourceRequested(lib.rs#[cfg(target_os = "windows")]) — COM event handler that returns a synthetic403 Forbiddenresponse (SetResponse) for external HTTP(S) requests (non-localhost). - macOS
WKContentRuleList(lib.rs#[cfg(target_os = "macos")]) — compiled content-blocking rule that blocks allhttps?://.*requests to non-localhost domains at the WebKit engine level. - Linux Flatpak (
flatpak/io.github.fjrevoredo.mini-diarium.yml) — no--share=networkinfinish-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
- Current schema is v14:
entries_ftswas removed in v4 for security (it stored plaintext). v5 addedid INTEGER PRIMARY KEY AUTOINCREMENTfor multi-entry-per-date support. v6 added thedb_settingstable (require_all_authflag + HKDF-SHA256 MAC) to bind multi-auth requirement to the encrypted database rather thanconfig.json. v7 addedtags(AES-256-GCM encrypted name, HKDF-SHA256 keyed fingerprint for dedup) andentry_tags(association table withON DELETE CASCADEon both sides). v8 addedcustom_fonts(unencrypted BLOB storage for user-uploaded font files;UNIQUE(family, weight)constraint; Regular weight required before Bold). v9 addedentry_metadata_encrypted(nullable BLOB on theentriestable, encrypted with the journal master key, context "entry_metadata") to store per-entry metadata such as font family and font size. v10 addedimages(content-addressed encrypted image store; HKDF-SHA256 fingerprint for dedup; AES-256-GCM encrypteddataBLOB) andentry_images(junction table withON DELETE CASCADEonentry_id,ON DELETE RESTRICTonimage_id). v11 addedthumbnail_data,thumbnail_mime_type,width,height,byte_size,thumbnail_versioncolumns toimages(nullable; backfilled lazily). v12 addedpreview_enc BLOB(nullable; AES-256-GCM encrypted 200-char plaintext preview; context"entry_preview"; populated byinsert_entry/update_entryon every save; NULL for entries not yet saved since v12 upgrade;get_entries_for_timelinefalls back to full-text decryption when NULL). v13 addedlocked 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 dedicatedset_entry_lockedcommand (targeted UPDATE, no re-encryption);get_locked_entry_datesfeeds 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, andremove_attachment_from_entrycheck it inside their write unit and returndb::ERR_ENTRY_LOCKED(exactly"entry is locked"). Commands must pass that error through unwrapped (mapTauriErrormatches it exactly) and must not add their own pre-check;delete_entry_if_emptymaps it toOk(false)because its auto-lock and app-close callers cannot show an error. The raw primitivesinsert_entry/update_entryskip the lock and the image links, so the façade exports them only withtest-support(fixtures and benches); production code uses the*_with_imageswrites. Seecrates/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 inskipped_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 addedattachments(content-addressed encrypted file store, TODO-0114: HKDF-SHA256 fingerprint for dedup viaimage_fingerprint— safe to reuse because it is a separate table; AES-256-GCM encrypteddata; plaintextmime_type+byte_size) andentry_attachments(link table; the per-entry file name is encrypted on the link row;ON DELETE RESTRICTon bothentry_idandattachment_id— TODO-0123:entry_idmust 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_idremoves the links explicitly before the entry). Ownership is explicit: the links change only throughadd_entry_attachment/remove_entry_attachment, per-entry backup restore (crate-privatelink_attachment), anddelete_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 bycleanup_orphaned_attachmentson remove and indelete_entry_by_id. An entry with attachments is never "empty":delete_entry_if_emptyrefuses it andentry_has_contentreports true. Migrations run v3→v4 … v13→v14 viaapply_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. - Always use
open_connection/open_connection_in_memory— neverConnection::opendirectly:PRAGMA foreign_keysis a per-connection setting that SQLite resets on every new connection. Forgetting to set it meansON DELETE CASCADE/ON DELETE RESTRICTdeclarations onentry_tags,entry_images, and any future tables are silently inert. The helpers indb/schema/create.rs(open_connection(path)for file DBs,open_connection_in_memory()for test DBs) apply the pragma automatically. In tests that constructDatabaseConnectionby hand, callopen_connection_in_memory()rather thanConnection::open_in_memory(). Never bypass this with a directConnection::opencall. - Command registration is two places: New commands must be added to both
commands/mod.rs(module declaration) andgenerate_handler![]inlib.rs. Missing either causes silent failures or compile errors. - Import behavior (no merge): Parsers in
import/*.rsreturnVec<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. - Auth slots (v3 schema): Each auth method stores its own wrapped copy of the master key in
auth_slots.remove_auth_methodrefuses to delete the last slot (minimum one required).change_passwordre-wraps the master key in O(1) — no entry re-encryption needed.verify_passwordexists as a side-effect-free check used before multi-step operations. Therequire_all_authflag (v6 schema) lives in thedb_settingstable insidediary.db, integrity-protected by an HKDF-SHA256 MAC derived from the master key — tampering with the row enforces the guard via a fail-safe. Seedocs/decisions/2026-05-settings-storage-taxonomy.mdfor the full settings storage taxonomy and when to usedb_settingsvs.config.json. - Plugin registry is initialized once at startup in
lib.rs.setup(). It reads{app_data_dir}/plugins/for.rhaiscripts (central location, shared across all journals). The registry is stored asState<Mutex<PluginRegistry>>. Lock it only throughcommands::plugin::lock_registry, which recovers the mutex after a plugin panic (TODO-0147). - Rhai's
exportkeyword is reserved: Export plugin scripts must usefn format_entries(entries)instead offn export(entries). TheRhaiExportPluginwrapper calls"format_entries"internally. - Rhai AST requires
unsafe impl Send + Sync: Therhai::ASTtype does not implementSend + Syncin the current version. Theunsafeimpls onRhaiImportPluginandRhaiExportPluginare required and justified: AST is immutable after compilation, and Engine is created fresh per invocation. - WebView-level spell checking is a Linux-only concern (TODO-0081, issue #227): WebView2 and WKWebView route the HTML
spellcheckattribute to an OS-native text checker, so on Windows/macOS the frontend attribute is the whole feature. WebKitGTK runs no checker untilset_spell_checking_enabled(true)is called on theWebKitWebContext, which defaults to off — sospellcheck::applyhas a#[cfg(target_os = "linux")]body and is a deliberate no-op elsewhere.webkit2gtkis a direct dependency only under[target.'cfg(target_os = "linux")'.dependencies]; it must stay version-compatible with tauri's soPlatformWebview::inner()returns the samewebkit2gtk::WebViewtype. Dictionaries are data, not code: the Flatpak manifest installs them to/app/share/hunspell/, other Linux packages rely on/usr/share/hunspell.tauri devis not a Flatpak dictionary test: it uses host dictionaries, so install the active language'shunspell-*package before using it to validate spellcheck. KeepDEFAULT_REGIONSinspellcheck.rs, thehunspell-dictsmodule inflatpak/io.github.fjrevoredo.mini-diarium.yml, and the shipped locale list insrc/i18n/locales/in sync — a UI language with no matching dictionary silently checks nothing. One documented exception: Chinese (Simplified) has no maintained Hunspellzh_CNdictionary, so the Flatpak ships none and the Linux warning is expected. - The global logger is a capture tap, and
Info-and-above records reach users (TODO-0090):lib.rsinstallsenv_loggervia.build()+log_capture::install(...)instead of.init(). Stderr behaviour is unchanged, but everyInfo/Warn/Errorrecord 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-onlyinfo!incommands/auth/auth_journals.rs). Paths are the one exception:log_capture::redactscrubs the home directory and absolute-path-shaped runs on read. Debug/Traceare 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::logre-enters its own lock and deadlocks. The dump's privacy boundary is enforced bytest_build_debug_dump_leaks_nothing_sensitiveincommands/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.
- Never interpolate user data into
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.rsis 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::openaborts 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_innertakes theDatabaseConnectionout ofDiaryStateand 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 touchesdiary.dbon the filesystem — moving or deleting it — must useLockCompletion::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 thedbpoison is cleared so the journal can be unlocked again. - The change counter is read from the live file and persisted in the manifest.
VACUUM INTOrebuilds 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_counteris the permanent guard. manifest.jsonis 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_contentenforces it.
Snapshot verification checks that the live master key decrypts content in the snapshot. It cannot "unwrap an auth slot": a
wrapped_keyis unwrapped by a credential to produce the master key, so holding the master key does not reverse it.- Failure semantics are deliberately asymmetric. A failed snapshot is logged and swallowed everywhere except before a schema migration, where
- Inspecting a snapshot opens a second decrypted database with a second master key (TODO-0098 Milestone 4).
commands/backup_inspect.rsis the IPC surface; the engine ismini_diarium_core::backup::inspect. Three constraints:- The handle lives in
DiaryState.inspection, not in its own managed state. That is what makeslock_diary_inner_withtear 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_usedalone destroys the restore point (KI-11) — and migrating one would rewrite the copy taken because a migration was the risk.list_snapshot_entriesadapts 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_credentialscompares the plaintext auth-slot rows and says so before the user is prompted. It needs no key and works while the journal is locked.
- The handle lives in
- Every open path calls the forward-compatibility guard first, and every schema bump must decide
min_reader_version(TODO-0122, ADR).db/schema/compat.rsrefuses a journal (ensure_journal_readable) or snapshot (ensure_backup_readable) whoseschema_version > SCHEMA_VERSION, unlessdb_settings.min_reader_versionparses 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_snapshotrepeats 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 mustDELETEthe row. When in doubt, delete it. Write the state explicitly in the migration step (inside its transaction) and increate_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_VERSIONandEXPECTED_MARKERincompat.rstests;test_fresh_and_migrated_journals_agree_on_the_markerchecks thatcreate_schemaand the migrations agree. The full checklist is the module doc ofcompat.rs.
- A new open path must call the guard directly after opening the SQLite file, before any write (
Common Task Checklists
Adding a New Tauri Command
- Write the function in the appropriate
src-tauri/src/commands/*.rsfile (or create a new module and add it tocommands/mod.rs) - Register in
lib.rsgenerate_handler![]macro - 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)
- Create
crates/mini-diarium-core/src/import/FORMAT.rswith aparse_FORMAT(content: &str) -> Result<Vec<DiaryEntry>, String>function - Add
pub(crate) mod FORMAT;tocrates/mini-diarium-core/src/import/mod.rs(parsers stay sealed to the crate; the plugin registry is the only public path) - Add a builtin wrapper struct in
crates/mini-diarium-core/src/plugin/builtins.rsimplementingImportPlugin(orExportPlugin), register inregister_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.
| Layer | File | What it provides |
| Core scan | crates/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 command | src-tauri/src/commands/search.rs | Thin #[tauri::command] search_entries wrapper over mini_diarium_core::search::search_entries; re-exports SearchResult/SearchResponse |
| Frontend wrapper | src/lib/tauri/search.ts | SearchResult interface + searchEntries(query) async function returning SearchResponse |
| Frontend state | src/state/search.ts | searchQuery, searchResults, isSearching signals |
| Frontend components | src/components/search/SearchOverlay.tsx | Palette-style dialog mounting SearchBar + SearchResults (in MainLayout) |
src/components/search/SearchBar.tsx | Debounced input with a monotonic latest-wins guard | |
src/components/search/SearchResults.tsx | Results 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:
- 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).
- Schema migration — bump
SCHEMA_VERSIONindb/schema/mod.rsand add a migration step indb/schema/migrations/.
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
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.

