Files
thoughtsync/android/ffi/src/lib.rs
T
bvandeusen 95aa10c2c3
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Remove the title field — a note is named by its first line
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00

861 lines
35 KiB
Rust

//! 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<Arc<Self>, 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<Vec<Note>, 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<Note, CoreError> {
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<Note, CoreError> {
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<NoteEdit>) -> Result<Note, CoreError> {
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<Vec<Note>, 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<Vec<Note>, 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<Vec<Label>, 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<Note, CoreError> {
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<Note, CoreError> {
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<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_item(&conn, &note_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<Note, CoreError> {
self.patch_item(&note_id, &item_id, serde_json::json!({ "text": text }))
}
pub fn set_item_checked(
&self,
note_id: String,
item_id: String,
checked: bool,
) -> Result<Note, CoreError> {
self.patch_item(
&note_id,
&item_id,
serde_json::json!({ "checked": checked }),
)
}
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_item(&conn, &note_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<Note, CoreError> {
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<Note, CoreError> {
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<String>,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_labels(&conn, &note_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<Label, CoreError> {
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<SyncStatus, CoreError> {
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<bool, CoreError> {
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<ProbeResult, CoreError> {
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<Identity, CoreError> {
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<Identity, CoreError> {
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<RevokeOutcome, CoreError> {
// 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<Option<ClientUpdate>, 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<SyncOutcome, CoreError> {
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<Note, CoreError> {
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<u32>,
) -> 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(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
color: "default".to_string(),
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\nmilk"))
.expect("create should succeed");
assert_eq!(created.body, "Groceries\nmilk");
let fetched = app
.get_note(created.id.clone())
.expect("get should succeed");
assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3).
assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok();
}
/// Every note 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 a_note_is_named_by_its_first_line() {
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.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok();
}
/// The hole that made removing the title unsafe until checklists stopped being
/// their own kind of thing: a note with no body text still needs a name.
#[test]
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
assert_eq!(created.display_title, "milk");
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 {
body: "Packing".to_string(),
color: "default".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\nbook 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\nbody")).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
}
}
}