//! uniffi bindings: `thoughtsync-core` as seen from Kotlin. //! //! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop //! — a thin shim over the shared core, holding no logic of its own. If something here //! starts making decisions about notes or sync, it belongs in the core where the //! desktop gets it too (Scribe note 2730). //! //! ## Shape //! //! One `ThoughtSync` object holds the store and the blob directory, mirroring how //! Tauri manages them as app state. Kotlin constructs it once, keeps it for the //! process lifetime, and calls methods on it. //! //! ## Async //! //! The sync engine is reqwest all the way down, so it needs a reactor. Async methods //! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin //! `suspend` functions driven by a tokio runtime on the Rust side. //! //! Cancellation works, and not by accident: when a coroutine is cancelled uniffi //! drops the Rust future, and none of the core's async paths hold the store lock //! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has //! been enforcing that all along. A cancelled sync therefore leaves the store //! consistent; it simply hasn't stamped `last_sync_at`, which is only written after //! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor. //! //! ## A known consequence of the release profile //! //! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi //! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as //! an exception; with `abort` it takes the process down instead. That is the same //! behaviour the desktop already has, so no surface is worse off than another — but //! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever //! turns out to be recoverable enough that a phone should survive it. pub mod models; use std::path::PathBuf; use std::sync::Arc; use thoughtsync_core::local::{self, Db}; use thoughtsync_core::sync::blobs::BlobStore; use thoughtsync_core::sync::{client, compat, engine, push, state}; use models::{ patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, }; uniffi::setup_scaffolding!(); /// Everything that can go wrong, as a Kotlin exception. /// /// The core reports failures as plain `String`s today, so most of them land in /// `Store` or `Network` by where they were raised rather than by a distinction the /// core actually draws. `NotLinked` is the exception and earns its own variant: it /// is the one failure that is a NORMAL state rather than a fault — an unlinked app is /// working exactly as intended — and the UI's response is to offer linking, not to /// show an error. #[derive(Debug, thiserror::Error, uniffi::Error)] // FLAT, so the Kotlin side gets the message on `Throwable` where it belongs. // // Without this, uniffi generates an exception subclass with a `message` PROPERTY // per variant — which collides with `Throwable.message` and fails to compile: // "'message' hides member of supertype 'Throwable' and needs an 'override' // modifier". Renaming the field would dodge the collision but leave // `e.message` null in Kotlin, so every call site would have to know the variant // just to read the text. // // Flat keeps what actually matters: each variant is still its own Kotlin // subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is // still exhaustive. Only the FIELDS stop crossing, and the Display string — // which is the field, for every variant that has one — comes through as the // exception message. #[uniffi(flat_error)] pub enum CoreError { /// No server is linked. Not a fault; the app is local-first and this is its /// resting state. #[error("this device isn't linked to a server")] NotLinked, /// The on-device store failed. #[error("{message}")] Store { message: String }, /// Talking to the server failed, or it refused. #[error("{message}")] Network { message: String }, } impl CoreError { fn store(e: impl std::fmt::Display) -> Self { CoreError::Store { message: e.to_string(), } } fn network(e: impl std::fmt::Display) -> Self { CoreError::Network { message: e.to_string(), } } } /// The client handle: the on-device store plus the attachment directory beside it. /// /// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store /// behind its mutex, the blob store being a path — which is what lets uniffi share /// one instance across coroutines. #[derive(uniffi::Object)] pub struct ThoughtSync { db: Db, blobs: BlobStore, } #[uniffi::export] impl ThoughtSync { /// Open (creating on first run) the store under `data_dir`, and the attachment /// directory beside it. /// /// `data_dir` comes from Kotlin because only Android knows where its app-private /// storage is; the core must not guess at a platform path. The layout inside is /// the core's business and matches the desktop's exactly — `thoughtsync.db` and /// `blobs/` — so a store is readable by any client that opens it. #[uniffi::constructor] pub fn new(data_dir: String) -> Result, CoreError> { let dir = PathBuf::from(data_dir); std::fs::create_dir_all(&dir).map_err(CoreError::store)?; let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?; log::info!("local store ready — {}", local::summary(&db)); let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?; Ok(Arc::new(ThoughtSync { db, blobs })) } /// A one-line count summary, for the boot log. pub fn summary(&self) -> String { local::summary(&self.db) } // ─────────────────────────────── notes ─────────────────────────────── pub fn list_notes(&self, query: NoteQuery) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?; Ok(notes.into_iter().map(Note::from).collect()) } pub fn get_note(&self, id: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::get_note(&conn, &id) .map(Note::from) .map_err(CoreError::store) } pub fn create_note(&self, draft: NoteDraft) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::create_note(&conn, &draft.into()) .map(Note::from) .map_err(CoreError::store) } /// Apply a batch of field edits. See `NoteEdit` for why this is a list rather /// than a struct of nullable fields. pub fn update_note(&self, id: String, edits: Vec) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::update_note(&conn, &id, &patch_from(edits)) .map(Note::from) .map_err(CoreError::store) } /// Full-text search across titles, bodies and checklist items. /// /// The core owns the query — it searches the same columns the desktop and web /// search, so "what matches" cannot drift between surfaces. Filtering the /// board list in Kotlin would have been less code and a different product. pub fn search_notes(&self, query: String) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let notes = local::store::search(&conn, &query).map_err(CoreError::store)?; Ok(notes.into_iter().map(Note::from).collect()) } /// Notes carrying a reminder, soonest first. /// /// A dedicated call rather than a board `view`, because that is how the core /// models it — `list_notes` only understands trashed/archived/default. pub fn reminder_notes(&self) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let notes = local::store::reminders(&conn).map_err(CoreError::store)?; Ok(notes.into_iter().map(Note::from).collect()) } /// Every label with its note count, for the navigation drawer. pub fn list_labels(&self) -> Result, CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let labels = local::store::list_labels(&conn).map_err(CoreError::store)?; Ok(labels.into_iter().map(Label::from).collect()) } pub fn trash_note(&self, id: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::trash(&conn, &id) .map(Note::from) .map_err(CoreError::store) } pub fn restore_note(&self, id: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::restore(&conn, &id) .map(Note::from) .map_err(CoreError::store) } /// Remove a note permanently. /// /// Returns nothing, unlike every other mutation here: there is no note left to /// return. The core also records a pending delete, so a linked device tells the /// server rather than having the next pull resurrect the row. pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; local::store::delete_forever(&conn, &id).map_err(CoreError::store) } // ──────────────────────────── checklist items ──────────────────────────── // // Every one of these returns the whole reloaded note rather than the item it // touched. That is the core's shape, and it is the right one for a UI: ticking // a box changes `updated_at` and can change what the board shows, so handing // back only the item would leave Kotlin to guess at the rest. pub fn add_item(&self, note_id: String, text: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::add_item(&conn, ¬e_id, &text) .map(Note::from) .map_err(CoreError::store) } /// Retitle one item. /// /// Split from `set_item_checked` rather than exposing the core's /// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an /// optional-field struct cannot say "leave this alone" in Kotlin without /// colliding with "set it to null", and two unambiguous calls beat one /// ambiguous one when each is three lines. pub fn set_item_text( &self, note_id: String, item_id: String, text: String, ) -> Result { self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text })) } pub fn set_item_checked( &self, note_id: String, item_id: String, checked: bool, ) -> Result { self.patch_item( ¬e_id, &item_id, serde_json::json!({ "checked": checked }), ) } pub fn delete_item(&self, note_id: String, item_id: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::delete_item(&conn, ¬e_id, &item_id) .map(Note::from) .map_err(CoreError::store) } // ─────────────────────────────── reminders ─────────────────────────────── /// Clear the reminder, marking it dealt with. /// /// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same /// thing: the core reserves this one for "the reminder fired and is finished", /// which is where recurrence advancement lands when it is built. A UI that /// called the generic clear instead would silently stop recurring reminders /// from recurring the day that changes. pub fn complete_reminder(&self, id: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::complete_reminder(&conn, &id) .map(Note::from) .map_err(CoreError::store) } /// Push the reminder out by `minutes` from now. /// /// The core computes the new instant from its own clock rather than taking one /// from the caller — so "in an hour" means the same thing on every surface, /// and a phone with a skewed clock can't write a reminder the server reads as /// already past. pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::snooze_reminder(&conn, &id, minutes) .map(Note::from) .map_err(CoreError::store) } // ───────────────────────────────── labels ──────────────────────────────── /// Replace the note's MANUAL labels. /// /// `#tag` labels are owned by the body text and the core re-derives them on /// every body edit, so they are deliberately untouched here. A picker that /// sent the full visible set would strip a tag label the text still mandates — /// and the next keystroke in the body would put it straight back, which is the /// kind of fight a UI should never pick with its store. pub fn set_note_labels( &self, note_id: String, label_ids: Vec, ) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::set_labels(&conn, ¬e_id, &label_ids) .map(Note::from) .map_err(CoreError::store) } /// Find or create a label by name, returning it either way. /// /// Find-or-create rather than create: the core matches case-insensitively, so /// typing "Errands" when "errands" exists has to attach the existing label /// instead of minting a near-duplicate that then diverges on colour. pub fn create_label(&self, name: String) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::create_label(&conn, &name) .map(Label::from) .map_err(CoreError::store) } // ─────────────────────────────── sync ──────────────────────────────── pub fn sync_status(&self) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; state::status(&conn) .map(SyncStatus::from) .map_err(CoreError::store) } /// Whether anything is waiting to be sent — so the UI can show an honest /// "unsynced changes" state without running a sync to find out. pub fn has_pending(&self) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; push::has_pending(&conn).map_err(CoreError::store) } } /// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend` /// functions. Split into its own impl block so the runtime attribute — and the fact /// that everything in here touches the network — is visible at a glance. #[uniffi::export(async_runtime = "tokio")] impl ThoughtSync { /// Ask a server who it is, without committing to anything. Called as the user /// finishes typing an address, so they see what answered before handing over /// credentials. pub async fn probe(&self, url: String) -> Result { client::probe(&url) .await .map(ProbeResult::from) .map_err(CoreError::network) } /// Pair with a server using an email/password, minting a device token named for /// this phone. /// /// The handshake runs FIRST, and an incompatible server aborts before any /// credential is sent — an incompatible server is exactly the case where a later /// failure would be hardest to attribute. pub async fn link_with_password( &self, url: String, email: String, password: String, device_name: String, ) -> Result { let probe = client::probe(&url).await.map_err(CoreError::network)?; if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility { return Err(CoreError::Network { message: reason.clone(), }); } let (token, identity) = client::device_login(&probe.base_url, &email, &password, &device_name) .await .map_err(CoreError::network)?; self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?; Ok(identity.into()) } /// Pair using a device token pasted from the web app — for anyone who would /// rather not type a password into an app, or whose account is behind SSO. /// /// The token is verified before it is stored, so a copy/paste slip fails here /// rather than at the next sync. pub async fn link_with_token(&self, url: String, token: String) -> Result { let probe = client::probe(&url).await.map_err(CoreError::network)?; if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility { return Err(CoreError::Network { message: reason.clone(), }); } let identity = client::fetch_identity(&probe.base_url, &token) .await .map_err(CoreError::network)?; self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?; Ok(identity.into()) } /// Stop syncing, and retire this device's token on the server. /// /// The local half is unconditional. Someone unlinking because the phone is being /// sold or handed on must not be held to it by a server that is offline or gone, /// so the revoke is attempted first, its outcome returned for the UI to report /// honestly, and the link cleared either way. pub async fn unlink(&self) -> Result { // Read and release before the network call: a std MutexGuard isn't Send, so // it cannot be held across an await, and holding the store through a // round-trip would freeze every note operation in the UI. let link = { let conn = self.db.conn().map_err(CoreError::store)?; let current = state::read(&conn).map_err(CoreError::store)?; current.server_url.zip(current.device_token) }; let revoked = match &link { Some((base_url, token)) => client::revoke_self(base_url, token).await, None => client::RevokeOutcome::Skipped, }; let conn = self.db.conn().map_err(CoreError::store)?; state::clear_link(&conn).map_err(CoreError::store)?; log::info!("unlinked from server (server-side token: {revoked:?})"); Ok(revoked.into()) } /// Run one full sync: push local changes, then pull the server's. /// /// The only sync entry point, on purpose. Push and pull exist separately inside /// the core, but offering a bare "pull" would let the UI overwrite unsent local /// edits — the ordering isn't a suggestion, it's what keeps them. /// The Android client the linked server is offering, if any. /// /// `None` covers two different-looking situations that are one answer to the /// app: this server has no client, or it has one and it is not newer than what /// is already installed. Comparing here rather than in Kotlin keeps the rule — /// version CODE decides, never the name — in the layer that also has to get it /// right for the desktop. pub async fn client_update( &self, installed_version_code: i64, ) -> Result, CoreError> { let (base_url, token) = self.credentials()?; let release = client::fetch_client_release(&base_url, &token) .await .map_err(CoreError::network)?; Ok(release .filter(|r| r.version_code > installed_version_code) .map(ClientUpdate::from)) } /// Download that client to `dest_path`, verified. /// /// Takes the destination rather than choosing one: only Android knows a /// directory its own package installer can read from, and the core has no /// business guessing at platform paths — the same reason `ThoughtSync::new` /// takes a data dir. pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> { let (base_url, token) = self.credentials()?; let release = client::fetch_client_release(&base_url, &token) .await .map_err(CoreError::network)? // Re-read rather than trusting what the caller was shown: the server // may have published a new build between the check and the tap, and // downloading against a stale digest would fail verification on bytes // that are perfectly good. .ok_or_else(|| { CoreError::network("This server no longer has an Android client.".to_string()) })?; client::download_client( &base_url, &token, &release, std::path::Path::new(&dest_path), ) .await .map_err(CoreError::network) } pub async fn sync_now(&self) -> Result { let (base_url, token) = self.credentials()?; engine::run_cycle(&self.db, &self.blobs, &base_url, &token) .await .map(SyncOutcome::from) .map_err(CoreError::network) } } /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// block names, so these stay Rust-side. impl ThoughtSync { /// Apply a `{text}` or `{checked}` patch to one checklist item. /// /// The two public setters differ only in the key they write, and the lock + /// convert + map-error dance around it is identical, so it lives once here. fn patch_item( &self, note_id: &str, item_id: &str, changes: serde_json::Value, ) -> Result { let conn = self.db.conn().map_err(CoreError::store)?; local::store::update_item(&conn, note_id, item_id, &changes) .map(Note::from) .map_err(CoreError::store) } /// The server URL + token, or the `NotLinked` state. Every networked call needs /// exactly this, and none of them may hold the lock past it. fn credentials(&self) -> Result<(String, String), CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; let current = state::read(&conn).map_err(CoreError::store)?; match (current.server_url, current.device_token) { (Some(url), Some(token)) => Ok((url, token)), _ => Err(CoreError::NotLinked), } } /// Persist a fresh link, adopting the server's retention window at the same time /// so the Trash view stops counting down against this device's offline default /// the moment it is no longer the policy in force. fn store_link( &self, base_url: &str, token: &str, retention_days: Option, ) -> Result<(), CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; state::set_link(&conn, base_url, token).map_err(CoreError::store)?; if let Some(days) = retention_days { state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?; } log::info!("linked to {base_url}"); Ok(()) } } #[cfg(test)] mod tests { use super::*; /// A scratch directory unique to this process and call. /// /// Process id + a counter rather than a uuid dependency: the FFI crate has no /// business pulling one in to name a temp folder, and this is the same approach /// the desktop's updater tests settled on. fn scratch_dir() -> String { use std::sync::atomic::{AtomicU32, Ordering}; static NEXT: AtomicU32 = AtomicU32::new(0); let dir = std::env::temp_dir().join(format!( "thoughtsync-ffi-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); dir.to_string_lossy().into_owned() } fn draft(title: &str, body: &str) -> NoteDraft { NoteDraft { title: title.to_string(), body: body.to_string(), color: "default".to_string(), kind: None, items: None, } } /// The round trip the Android skeleton has to make: open a store in a directory /// that doesn't exist yet, write a note, read it back through the FFI types. /// Proving it here means a failure on device is an Android problem, not a /// binding problem. #[test] fn creates_a_store_and_round_trips_a_note() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let created = app .create_note(draft("Groceries", "milk")) .expect("create should succeed"); assert_eq!(created.title.as_deref(), Some("Groceries")); assert_eq!(created.body, "milk"); let fetched = app .get_note(created.id.clone()) .expect("get should succeed"); assert_eq!(fetched.id, created.id); assert_eq!(fetched.display_title, "Groceries"); std::fs::remove_dir_all(&dir).ok(); } /// A body-only note still has to be nameable — that is what `display_title` is /// for, and the Android board relies on it exactly as the desktop does. #[test] fn body_only_notes_still_have_a_display_title() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let created = app .create_note(draft("", "just a thought")) .expect("create should succeed"); assert_eq!(created.title, None); assert_eq!(created.display_title, "just a thought"); std::fs::remove_dir_all(&dir).ok(); } /// Clearing a field and setting one are different edits, and the difference has /// to survive the trip through the patch object. #[test] fn edits_can_both_set_and_clear_a_title() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app.create_note(draft("First", "body")).expect("create"); let renamed = app .update_note( note.id.clone(), vec![NoteEdit::Title { value: "Second".to_string(), }], ) .expect("rename"); assert_eq!(renamed.title.as_deref(), Some("Second")); let cleared = app .update_note(note.id.clone(), vec![NoteEdit::ClearTitle]) .expect("clear"); assert_eq!( cleared.title, None, "ClearTitle must null the column, not set it to an empty string — the \ distinction is why NoteEdit is a list rather than a struct of options" ); std::fs::remove_dir_all(&dir).ok(); } /// An unlinked app is a normal, working app. Asking it to sync is the one /// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can /// offer linking rather than show an error. #[test] fn syncing_unlinked_reports_not_linked() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let status = app.sync_status().expect("status should read"); assert!(!status.linked); assert_eq!(status.server_url, None); assert!(matches!(app.credentials(), Err(CoreError::NotLinked))); std::fs::remove_dir_all(&dir).ok(); } /// The editor's whole checklist loop, in one pass: add a row, tick it, retitle /// it, drop it. Each call returns the reloaded note, which is what the UI /// splices back into the board rather than re-querying. #[test] fn checklist_items_can_be_added_ticked_retitled_and_removed() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app .create_note(NoteDraft { title: "Packing".to_string(), body: String::new(), color: "default".to_string(), kind: Some("list".to_string()), items: Some(vec!["socks".to_string()]), }) .expect("create"); assert_eq!(note.items.len(), 1); let with_two = app .add_item(note.id.clone(), "charger".to_string()) .expect("add"); assert_eq!(with_two.items.len(), 2); // Appended, not prepended — a new row belongs at the bottom of the list the // user is looking at. assert_eq!(with_two.items[1].text, "charger"); let item_id = with_two.items[1].id.clone(); let ticked = app .set_item_checked(note.id.clone(), item_id.clone(), true) .expect("tick"); assert!(ticked.items[1].checked); assert_eq!( ticked.items[1].text, "charger", "ticking a box must not disturb its text — the two setters write \ different columns and neither may clear the other" ); let renamed = app .set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string()) .expect("rename"); assert_eq!(renamed.items[1].text, "usb-c cable"); assert!( renamed.items[1].checked, "and the same in the other direction" ); let trimmed = app .delete_item(note.id.clone(), item_id) .expect("delete item"); assert_eq!(trimmed.items.len(), 1); assert_eq!(trimmed.items[0].text, "socks"); std::fs::remove_dir_all(&dir).ok(); } /// A `#tag` in the body owns its label. The picker replaces MANUAL labels only, /// so sending an empty set must not strip one the text still mandates — /// otherwise the next body edit would re-derive it and the UI would appear to /// fight itself. #[test] fn setting_labels_leaves_tag_derived_ones_alone() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app .create_note(draft("Trip", "book the ferry #travel")) .expect("create"); assert_eq!( note.labels.len(), 1, "the #tag should have attached a label" ); assert!(note.labels[0].via_tag); let errands = app .create_label("errands".to_string()) .expect("create label"); let tagged = app .set_note_labels(note.id.clone(), vec![errands.id.clone()]) .expect("set labels"); assert_eq!(tagged.labels.len(), 2); let cleared = app .set_note_labels(note.id.clone(), vec![]) .expect("clear manual labels"); assert_eq!(cleared.labels.len(), 1); assert!(cleared.labels[0].via_tag); // Find-or-create, not create: a second "Errands" must be the same label, // or the picker mints near-duplicates that then diverge on colour. let again = app .create_label("Errands".to_string()) .expect("create label again"); assert_eq!(again.id, errands.id); std::fs::remove_dir_all(&dir).ok(); } /// Deleting forever has to actually remove the row, and the note must then be /// unreadable rather than merely hidden. #[test] fn deleting_forever_removes_the_note() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app.create_note(draft("Ephemeral", "body")).expect("create"); app.delete_note_forever(note.id.clone()) .expect("delete forever"); assert!( app.get_note(note.id.clone()).is_err(), "a permanently deleted note must not still load" ); std::fs::remove_dir_all(&dir).ok(); } /// Snooze writes a future instant from the CORE's clock; complete clears it. #[test] fn reminders_can_be_snoozed_and_completed() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app.create_note(draft("Call back", "")).expect("create"); assert_eq!(note.remind_at, None); let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze"); let at = snoozed.remind_at.expect("snoozing must set a reminder"); let parsed = chrono_free_parse(&at); assert!( parsed > 0, "the reminder must be a parseable RFC3339 instant, got {at:?}" ); let done = app.complete_reminder(note.id.clone()).expect("complete"); assert_eq!(done.remind_at, None); std::fs::remove_dir_all(&dir).ok(); } /// The path the notification's Done button takes. /// /// Completing a RECURRING reminder must move it, not end it — this is the /// behaviour the web has had all along and the clients did not, which made /// "Done" on a daily reminder quietly the last time it ever fired. #[test] fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() { let dir = scratch_dir(); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let note = app .create_note(draft("Water the plants", "")) .expect("create"); let armed = app .update_note( note.id.clone(), vec![ NoteEdit::RemindAt { value: "2026-07-01T09:00:00.000Z".into(), }, NoteEdit::Recurrence { value: "daily".into(), }, ], ) .expect("arm a daily reminder"); assert_eq!(armed.recurrence.as_deref(), Some("daily")); let done = app.complete_reminder(note.id.clone()).expect("complete"); let next = done .remind_at .expect("a daily reminder must still have a next occurrence"); assert!( next.as_str() > "2026-07-01T09:00:00.000Z", "it must move FORWARD, got {next:?}" ); assert!( next.ends_with("T09:00:00.000Z"), "the time of day is what was asked for and must survive, got {next:?}" ); assert_eq!( done.recurrence.as_deref(), Some("daily"), "the rule outlives the occurrence" ); // A one-off clears BOTH fields, so an unrecognised rule cannot linger // invisibly on a note with no reminder. let once = app .create_note(draft("Post the letter", "")) .expect("create"); app.update_note( once.id.clone(), vec![NoteEdit::RemindAt { value: "2026-07-01T09:00:00.000Z".into(), }], ) .expect("arm a one-off"); let finished = app.complete_reminder(once.id.clone()).expect("complete"); assert_eq!(finished.remind_at, None); assert_eq!(finished.recurrence, None); std::fs::remove_dir_all(&dir).ok(); } /// A crude RFC3339 sanity check that doesn't pull a date crate into this /// crate's dev-dependencies to assert one field is well-formed. fn chrono_free_parse(raw: &str) -> usize { if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') { raw.len() } else { 0 } } }